@saadnvd1/agent-os 0.3.735 → 0.3.737
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/hosts/[id]/agents/route.ts +22 -0
- package/app/api/hosts/[id]/route.ts +24 -1
- package/app/api/hosts/route.ts +3 -0
- package/app/api/machine/browser/route.ts +22 -0
- package/app/api/machine/route.ts +10 -0
- package/components/Claims/ClaimsSheet.tsx +19 -1
- package/components/Claims/claims-ui.test.ts +1 -1
- package/components/Claims/format.ts +1 -1
- package/components/Draft/AgentModelChip.test.ts +43 -0
- package/components/Draft/AgentModelChip.tsx +162 -0
- package/components/Draft/Chip.tsx +9 -2
- package/components/Draft/DraftChips.tsx +7 -45
- package/components/Draft/DraftPanel.tsx +10 -1
- package/components/Hosts/AccountMachines.tsx +3 -1
- package/components/Hosts/HostRow.tsx +57 -7
- package/components/Hosts/RenameHost.tsx +59 -0
- package/components/SessionList/MachineSwitcher.tsx +67 -0
- package/components/SessionList/SidebarHeader.tsx +4 -0
- package/components/SessionList/useSidebarData.ts +21 -3
- package/data/hosts/queries.ts +17 -0
- package/data/machines/index.ts +54 -0
- package/data/machines/keys.ts +5 -0
- package/hooks/useDraftRequests.ts +6 -1
- package/lib/db/migrations/20261023T1200_host_info.ts +20 -0
- package/lib/db/types.ts +4 -0
- package/lib/drafts.ts +8 -2
- package/lib/hosts/index.ts +43 -5
- package/lib/machines/browser.ts +37 -0
- package/lib/machines/catalog.ts +97 -0
- package/lib/machines/identity.ts +52 -0
- package/lib/machines/label.ts +36 -0
- package/lib/machines/ollama.ts +32 -0
- package/lib/machines/remote.ts +179 -0
- package/lib/machines/report.ts +8 -0
- package/lib/machines/types.ts +55 -0
- package/lib/peer/allowlist.ts +2 -0
- package/lib/peer/machines.ts +30 -2
- package/lib/sidebar/machines.ts +10 -0
- package/lib/start-options.ts +20 -4
- package/lib/worktree-deps/install.ts +4 -1
- package/package.json +1 -1
- package/stores/machines.ts +51 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import type { Host } from "@/lib/db";
|
|
7
|
+
import { useRenameHost } from "@/data/hosts";
|
|
8
|
+
|
|
9
|
+
/** A name of the person's own for a machine, shown on every screen. */
|
|
10
|
+
export function RenameHost({
|
|
11
|
+
host,
|
|
12
|
+
onDone,
|
|
13
|
+
}: {
|
|
14
|
+
host: Host;
|
|
15
|
+
onDone: () => void;
|
|
16
|
+
}) {
|
|
17
|
+
const rename = useRenameHost();
|
|
18
|
+
const [name, setName] = useState(host.name);
|
|
19
|
+
const save = () =>
|
|
20
|
+
rename.mutate({ id: host.id, name }, { onSuccess: onDone });
|
|
21
|
+
return (
|
|
22
|
+
<form
|
|
23
|
+
className="mt-2 flex flex-wrap items-center gap-2"
|
|
24
|
+
onSubmit={(e) => {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
save();
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
<Input
|
|
30
|
+
value={name}
|
|
31
|
+
onChange={(e) => setName(e.target.value)}
|
|
32
|
+
aria-label={`Name for ${host.name}`}
|
|
33
|
+
maxLength={60}
|
|
34
|
+
autoFocus
|
|
35
|
+
className="h-11 min-w-0 flex-1 sm:h-9"
|
|
36
|
+
/>
|
|
37
|
+
<Button
|
|
38
|
+
type="submit"
|
|
39
|
+
className="min-h-11 sm:min-h-9"
|
|
40
|
+
disabled={rename.isPending || !name.trim()}
|
|
41
|
+
>
|
|
42
|
+
Save
|
|
43
|
+
</Button>
|
|
44
|
+
<Button
|
|
45
|
+
type="button"
|
|
46
|
+
variant="ghost"
|
|
47
|
+
className="min-h-11 sm:min-h-9"
|
|
48
|
+
onClick={onDone}
|
|
49
|
+
>
|
|
50
|
+
Cancel
|
|
51
|
+
</Button>
|
|
52
|
+
{rename.error && (
|
|
53
|
+
<p className="text-destructive w-full text-xs">
|
|
54
|
+
{rename.error.message}
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
57
|
+
</form>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { Check, ChevronDown, Monitor } from "lucide-react";
|
|
6
|
+
import * as DM from "@/components/ui/dropdown-menu";
|
|
7
|
+
import { useHostsQuery } from "@/data/hosts";
|
|
8
|
+
import { useBrowserMachine } from "@/data/machines";
|
|
9
|
+
import { machineLabel } from "@/lib/machines/label";
|
|
10
|
+
import { machinesUi, machinesUiActions } from "@/stores/machines";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
|
|
13
|
+
// A quick way between the machines linked here: narrows the sidebar to one
|
|
14
|
+
// machine's sessions and starts new chats there. Hidden until one is linked.
|
|
15
|
+
export function MachineSwitcher() {
|
|
16
|
+
const { data: hosts = [], isSuccess } = useHostsQuery();
|
|
17
|
+
const { scopeHostId } = useSnapshot(machinesUi);
|
|
18
|
+
const browser = useBrowserMachine();
|
|
19
|
+
const machines = hosts.filter((h) => h.id === "local" || h.linked);
|
|
20
|
+
const current = machines.find((h) => h.id === scopeHostId) ?? null;
|
|
21
|
+
|
|
22
|
+
// A machine that was unlinked since goes back to all of them.
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (isSuccess && scopeHostId && !current) machinesUiActions.setScope(null);
|
|
25
|
+
}, [isSuccess, scopeHostId, current]);
|
|
26
|
+
|
|
27
|
+
if (machines.length < 2) return null;
|
|
28
|
+
return (
|
|
29
|
+
<DM.DropdownMenu>
|
|
30
|
+
<DM.DropdownMenuTrigger asChild>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
aria-label={`Machine: ${current ? machineLabel(current) : "All machines"}`}
|
|
34
|
+
className={cn(
|
|
35
|
+
"border-border/70 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.04] flex h-11 max-w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-xs md:h-8",
|
|
36
|
+
current && "border-primary/40 bg-primary/10 text-primary"
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
|
40
|
+
<span className="truncate">
|
|
41
|
+
{current ? machineLabel(current) : "All machines"}
|
|
42
|
+
</span>
|
|
43
|
+
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
44
|
+
</button>
|
|
45
|
+
</DM.DropdownMenuTrigger>
|
|
46
|
+
<DM.DropdownMenuContent align="start" className="max-w-80">
|
|
47
|
+
<DM.DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
|
|
48
|
+
Machine
|
|
49
|
+
</DM.DropdownMenuLabel>
|
|
50
|
+
{[null, ...machines].map((h) => (
|
|
51
|
+
<DM.DropdownMenuItem
|
|
52
|
+
key={h?.id ?? "all"}
|
|
53
|
+
className="min-h-11 md:min-h-8"
|
|
54
|
+
onClick={() => machinesUiActions.setScope(h?.id ?? null)}
|
|
55
|
+
>
|
|
56
|
+
<span className="flex-1 truncate">
|
|
57
|
+
{h ? machineLabel(h, browser.hostId) : "All machines"}
|
|
58
|
+
</span>
|
|
59
|
+
{(current?.id ?? null) === (h?.id ?? null) && (
|
|
60
|
+
<Check className="text-primary h-3.5 w-3.5" />
|
|
61
|
+
)}
|
|
62
|
+
</DM.DropdownMenuItem>
|
|
63
|
+
))}
|
|
64
|
+
</DM.DropdownMenuContent>
|
|
65
|
+
</DM.DropdownMenu>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { MachineSwitcher } from "./MachineSwitcher";
|
|
3
4
|
import {
|
|
4
5
|
Archive,
|
|
5
6
|
Star,
|
|
@@ -189,6 +190,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
189
190
|
]}
|
|
190
191
|
/>
|
|
191
192
|
</div>
|
|
193
|
+
<div className="w-full empty:hidden">
|
|
194
|
+
<MachineSwitcher />
|
|
195
|
+
</div>
|
|
192
196
|
</div>
|
|
193
197
|
);
|
|
194
198
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
5
|
import type { Session } from "@/lib/db";
|
|
6
6
|
import type { SessionGroup } from "@/lib/sidebar/shelves";
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
projectsInWorkspace,
|
|
12
12
|
} from "@/lib/sidebar/shelves";
|
|
13
13
|
import type { TaskState } from "@/lib/tasks/state";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
inMachineScope,
|
|
16
|
+
machineGroups,
|
|
17
|
+
onMachineOnly,
|
|
18
|
+
} from "@/lib/sidebar/machines";
|
|
15
19
|
import { orchestratorsToStart } from "@/lib/sidebar/orchestrator-row";
|
|
16
20
|
import { useSessionsQuery } from "@/data/sessions";
|
|
17
21
|
import { useLinkedHostNames } from "@/data/hosts";
|
|
@@ -23,6 +27,7 @@ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
|
23
27
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
24
28
|
import { useClock } from "@/hooks/useMinuteTick";
|
|
25
29
|
import { sidebarUi } from "@/stores/sidebarUi";
|
|
30
|
+
import { machinesUi, machinesUiActions } from "@/stores/machines";
|
|
26
31
|
import type { SessionStatus } from "./SessionList.types";
|
|
27
32
|
|
|
28
33
|
// One empty list for queries still loading, so memos below hold.
|
|
@@ -35,6 +40,10 @@ export function useSidebarData(
|
|
|
35
40
|
sessionStatuses: Record<string, SessionStatus> | undefined
|
|
36
41
|
) {
|
|
37
42
|
const ui = useSnapshot(sidebarUi);
|
|
43
|
+
const scope = useSnapshot(machinesUi).scopeHostId;
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
machinesUiActions.hydrate();
|
|
46
|
+
}, []);
|
|
38
47
|
// Wakes and 3-day settles are read from the clock.
|
|
39
48
|
const now = useClock();
|
|
40
49
|
|
|
@@ -98,6 +107,7 @@ export function useSidebarData(
|
|
|
98
107
|
sessions: sessions.filter(
|
|
99
108
|
(s) =>
|
|
100
109
|
!onMachineOnly(s, linked) &&
|
|
110
|
+
inMachineScope(s, scope) &&
|
|
101
111
|
inWorkspace(
|
|
102
112
|
s,
|
|
103
113
|
(id) => byId.get(id)?.workspace_id,
|
|
@@ -122,7 +132,14 @@ export function useSidebarData(
|
|
|
122
132
|
machines:
|
|
123
133
|
project || ui.orchestratorId
|
|
124
134
|
? []
|
|
125
|
-
: machineGroups(
|
|
135
|
+
: machineGroups(
|
|
136
|
+
{ ...common, sessions },
|
|
137
|
+
scope
|
|
138
|
+
? Object.fromEntries(
|
|
139
|
+
Object.entries(linked).filter(([id]) => id === scope)
|
|
140
|
+
)
|
|
141
|
+
: linked
|
|
142
|
+
),
|
|
126
143
|
};
|
|
127
144
|
}, [
|
|
128
145
|
now,
|
|
@@ -138,6 +155,7 @@ export function useSidebarData(
|
|
|
138
155
|
project,
|
|
139
156
|
ui.query,
|
|
140
157
|
ui.orchestratorId,
|
|
158
|
+
scope,
|
|
141
159
|
]);
|
|
142
160
|
|
|
143
161
|
// Per workspace, how many of its project sessions are working: the
|
package/data/hosts/queries.ts
CHANGED
|
@@ -61,6 +61,23 @@ export function useDeleteHost() {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Rename a machine; "local" renames this one. */
|
|
65
|
+
export function useRenameHost() {
|
|
66
|
+
const queryClient = useQueryClient();
|
|
67
|
+
return useMutation({
|
|
68
|
+
mutationFn: async ({ id, name }: { id: string; name: string }) =>
|
|
69
|
+
json<{ name: string }>(
|
|
70
|
+
await fetch(`/api/hosts/${encodeURIComponent(id)}`, {
|
|
71
|
+
method: "PATCH",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ name }),
|
|
74
|
+
})
|
|
75
|
+
),
|
|
76
|
+
// The name is on the claims, the sidebar and the chips too.
|
|
77
|
+
onSuccess: () => queryClient.invalidateQueries(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
export interface HostTestResult {
|
|
65
82
|
ok: boolean;
|
|
66
83
|
hostname?: string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useSnapshot } from "valtio";
|
|
4
|
+
import { machinesUi, machinesUiActions } from "@/stores/machines";
|
|
5
|
+
import type { HostCatalogResponse } from "@/lib/machines/types";
|
|
6
|
+
import type { BrowserMachine } from "@/lib/machines/browser";
|
|
7
|
+
import { machineKeys } from "./keys";
|
|
8
|
+
|
|
9
|
+
/** The agents and models a machine reports, for the new-session form. */
|
|
10
|
+
export function useMachineCatalog(hostId: string) {
|
|
11
|
+
return useQuery({
|
|
12
|
+
queryKey: machineKeys.agents(hostId),
|
|
13
|
+
queryFn: async () => {
|
|
14
|
+
const res = await fetch(
|
|
15
|
+
`/api/hosts/${encodeURIComponent(hostId)}/agents`
|
|
16
|
+
);
|
|
17
|
+
const data = await res.json().catch(() => ({}));
|
|
18
|
+
if (!res.ok) throw new Error(data.error || "Couldn't list its agents");
|
|
19
|
+
return data as HostCatalogResponse;
|
|
20
|
+
},
|
|
21
|
+
staleTime: 60_000,
|
|
22
|
+
retry: false,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The machine this browser is on: what the person set, else what the server
|
|
28
|
+
* could tell from the connection, else unknown.
|
|
29
|
+
*/
|
|
30
|
+
export function useBrowserMachine(): {
|
|
31
|
+
hostId: string | null;
|
|
32
|
+
detected: BrowserMachine["how"];
|
|
33
|
+
set: (hostId: string | null) => void;
|
|
34
|
+
} {
|
|
35
|
+
const snap = useSnapshot(machinesUi);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
machinesUiActions.hydrate();
|
|
38
|
+
}, []);
|
|
39
|
+
const { data } = useQuery({
|
|
40
|
+
queryKey: machineKeys.browser(),
|
|
41
|
+
queryFn: async () => {
|
|
42
|
+
const res = await fetch("/api/machine/browser");
|
|
43
|
+
if (!res.ok) throw new Error("Couldn't tell");
|
|
44
|
+
return (await res.json()) as BrowserMachine;
|
|
45
|
+
},
|
|
46
|
+
staleTime: Infinity,
|
|
47
|
+
retry: false,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
hostId: snap.browserHostId ?? data?.hostId ?? null,
|
|
51
|
+
detected: data?.how ?? null,
|
|
52
|
+
set: machinesUiActions.setBrowserHost,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { machinesUi } from "@/stores/machines";
|
|
3
4
|
import { useEffect, useRef } from "react";
|
|
4
5
|
import { subscribe } from "valtio";
|
|
5
6
|
import { FolderGit2 } from "lucide-react";
|
|
@@ -64,7 +65,11 @@ export function useDraftRequests(options: Options) {
|
|
|
64
65
|
? { agentType: s.agent_type, model: s.model, access: s.chat_access }
|
|
65
66
|
: {};
|
|
66
67
|
const project = projects.find((p) => p.id === projectId) ?? null;
|
|
67
|
-
const draft = makeDraft(uuid(), project, carry, {
|
|
68
|
+
const draft = makeDraft(uuid(), project, carry, {
|
|
69
|
+
openPr,
|
|
70
|
+
workspaceId,
|
|
71
|
+
hostId: openPr ? null : machinesUi.scopeHostId,
|
|
72
|
+
});
|
|
68
73
|
draftsActions.put(draft);
|
|
69
74
|
show(draft.id);
|
|
70
75
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261023T1200_host_info",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// What a linked machine says about itself (GET /api/machine), kept so its
|
|
7
|
+
// name, OS and addresses show while it is offline. Foreign keys aren't
|
|
8
|
+
// enforced here, so deleteHost removes the row.
|
|
9
|
+
db.exec(`CREATE TABLE IF NOT EXISTS host_info (
|
|
10
|
+
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
|
11
|
+
name TEXT,
|
|
12
|
+
hostname TEXT,
|
|
13
|
+
os TEXT,
|
|
14
|
+
arch TEXT,
|
|
15
|
+
connect_machine_id TEXT,
|
|
16
|
+
addresses TEXT NOT NULL DEFAULT '[]',
|
|
17
|
+
fetched_at INTEGER NOT NULL DEFAULT 0
|
|
18
|
+
)`);
|
|
19
|
+
},
|
|
20
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -325,4 +325,8 @@ export interface Host {
|
|
|
325
325
|
// who unlinked it.
|
|
326
326
|
peer_state?: "active" | "suspended" | "unlinked" | null;
|
|
327
327
|
peer_note?: string | null;
|
|
328
|
+
// What the machine reported about itself (host_info), when it has.
|
|
329
|
+
hostname?: string | null;
|
|
330
|
+
os?: string | null;
|
|
331
|
+
arch?: string | null;
|
|
328
332
|
}
|
package/lib/drafts.ts
CHANGED
|
@@ -63,7 +63,13 @@ export function newDraft(
|
|
|
63
63
|
id: string,
|
|
64
64
|
project: DraftProject | null,
|
|
65
65
|
carry: Carry,
|
|
66
|
-
opts: {
|
|
66
|
+
opts: {
|
|
67
|
+
openPr?: boolean;
|
|
68
|
+
now?: number;
|
|
69
|
+
workspaceId?: string | null;
|
|
70
|
+
// Where a scratch chat starts: the machine the sidebar is narrowed to.
|
|
71
|
+
hostId?: string | null;
|
|
72
|
+
} = {}
|
|
67
73
|
): Draft {
|
|
68
74
|
const real = project && !project.is_uncategorized ? project : null;
|
|
69
75
|
const agentType = real?.agent_type ?? carry.agentType ?? "claude";
|
|
@@ -71,7 +77,7 @@ export function newDraft(
|
|
|
71
77
|
id,
|
|
72
78
|
projectId: real?.id ?? null,
|
|
73
79
|
workspaceId: real ? null : (opts.workspaceId ?? null),
|
|
74
|
-
hostId: real?.host_id || "local",
|
|
80
|
+
hostId: real?.host_id || opts.hostId || "local",
|
|
75
81
|
agentType,
|
|
76
82
|
model: resolveModelForAgent(
|
|
77
83
|
agentType,
|
package/lib/hosts/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { getDb, type Host } from "../db";
|
|
3
|
+
import { machineName } from "../claims/machine";
|
|
4
|
+
import { setThisMachineName, thisMachine } from "../machines/identity";
|
|
3
5
|
import {
|
|
4
6
|
LOCAL_HOST_ID,
|
|
5
7
|
isValidSshTarget,
|
|
@@ -19,6 +21,18 @@ export const LOCAL_HOST: Host = {
|
|
|
19
21
|
created_at: "",
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
/** This machine as a host row, under its real (or user-set) name. */
|
|
25
|
+
export function localHost(): Host {
|
|
26
|
+
const me = thisMachine();
|
|
27
|
+
return {
|
|
28
|
+
...LOCAL_HOST,
|
|
29
|
+
name: me.name,
|
|
30
|
+
hostname: me.hostname,
|
|
31
|
+
os: me.os,
|
|
32
|
+
arch: me.arch,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
export function isRemoteHost(hostId: string | null | undefined): boolean {
|
|
23
37
|
return !!hostId && hostId !== LOCAL_HOST_ID;
|
|
24
38
|
}
|
|
@@ -38,18 +52,20 @@ export function listRemoteHosts(): Host[] {
|
|
|
38
52
|
(SELECT p.suspended_reason FROM peer_links p WHERE p.host_id = h.id),
|
|
39
53
|
(SELECT u.reason FROM peer_unlinked u WHERE u.host_id = h.id
|
|
40
54
|
ORDER BY u.at DESC LIMIT 1))
|
|
41
|
-
END AS peer_note
|
|
42
|
-
|
|
55
|
+
END AS peer_note,
|
|
56
|
+
i.hostname AS hostname, i.os AS os, i.arch AS arch
|
|
57
|
+
FROM hosts h LEFT JOIN host_info i ON i.host_id = h.id
|
|
58
|
+
ORDER BY h.sort_order, h.created_at`
|
|
43
59
|
)
|
|
44
60
|
.all() as Host[];
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
export function listHosts(): Host[] {
|
|
48
|
-
return [
|
|
64
|
+
return [localHost(), ...listRemoteHosts()];
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
export function getHost(hostId: string | null | undefined): Host | null {
|
|
52
|
-
if (!isRemoteHost(hostId)) return
|
|
68
|
+
if (!isRemoteHost(hostId)) return localHost();
|
|
53
69
|
return (
|
|
54
70
|
(getDb().prepare(`SELECT * FROM hosts WHERE id = ?`).get(hostId) as
|
|
55
71
|
Host | undefined) ?? null
|
|
@@ -59,7 +75,11 @@ export function getHost(hostId: string | null | undefined): Host | null {
|
|
|
59
75
|
/** A machine by its name (any case) or id. */
|
|
60
76
|
export function hostIdNamed(ref: string): string {
|
|
61
77
|
const want = ref.trim().toLowerCase();
|
|
62
|
-
if (
|
|
78
|
+
if (
|
|
79
|
+
["local", "here", "this machine"].includes(want) ||
|
|
80
|
+
machineName().toLowerCase() === want
|
|
81
|
+
)
|
|
82
|
+
return LOCAL_HOST_ID;
|
|
63
83
|
const host = listRemoteHosts().find(
|
|
64
84
|
(h) => h.id === ref || h.name.toLowerCase() === want
|
|
65
85
|
);
|
|
@@ -109,6 +129,7 @@ export function deleteHost(hostId: string): void {
|
|
|
109
129
|
}
|
|
110
130
|
db.prepare(`DELETE FROM host_links WHERE host_id = ?`).run(hostId);
|
|
111
131
|
db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
|
|
132
|
+
db.prepare(`DELETE FROM host_info WHERE host_id = ?`).run(hostId);
|
|
112
133
|
db.prepare(`DELETE FROM hosts WHERE id = ?`).run(hostId);
|
|
113
134
|
}
|
|
114
135
|
|
|
@@ -139,3 +160,20 @@ export function hostExecFile(
|
|
|
139
160
|
): Promise<ExecResult> {
|
|
140
161
|
return runFileOnTarget(sshTargetFor(hostId), file, args, timeout);
|
|
141
162
|
}
|
|
163
|
+
|
|
164
|
+
/** Rename a machine; this one's name is the one Work across machines shows. */
|
|
165
|
+
export function renameHost(hostId: string, raw: string): string {
|
|
166
|
+
if (!isRemoteHost(hostId)) return setThisMachineName(raw);
|
|
167
|
+
const name = raw.trim().slice(0, 60);
|
|
168
|
+
if (!name) throw new Error("Name is required");
|
|
169
|
+
const db = getDb();
|
|
170
|
+
if (!db.prepare(`SELECT 1 FROM hosts WHERE id = ?`).get(hostId))
|
|
171
|
+
throw new Error("Unknown host");
|
|
172
|
+
const clash = db
|
|
173
|
+
.prepare(`SELECT 1 FROM hosts WHERE lower(name) = lower(?) AND id != ?`)
|
|
174
|
+
.get(name, hostId);
|
|
175
|
+
if (clash || name.toLowerCase() === machineName().toLowerCase())
|
|
176
|
+
throw new Error(`Another machine is already called "${name}"`);
|
|
177
|
+
db.prepare(`UPDATE hosts SET name = ? WHERE id = ?`).run(name, hostId);
|
|
178
|
+
return name;
|
|
179
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which machine the browser is on, when the request says. Only what the
|
|
3
|
+
* server itself saw counts: the gate's trust verdict (loopback means an
|
|
4
|
+
* unproxied request from this machine) and the socket's remote address.
|
|
5
|
+
* Through Connect or a proxy the address is the tunnel's, so nothing is
|
|
6
|
+
* claimed and the person says so once per browser instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { plainAddress } from "../security/auth";
|
|
10
|
+
|
|
11
|
+
export interface BrowserMachine {
|
|
12
|
+
/** "local", a host id, or null when it can't be told. */
|
|
13
|
+
hostId: string | null;
|
|
14
|
+
how: "loopback" | "address" | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface KnownMachine {
|
|
18
|
+
hostId: string;
|
|
19
|
+
addresses: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function browserMachine(
|
|
23
|
+
trust: string | null,
|
|
24
|
+
remote: string | null,
|
|
25
|
+
own: string[],
|
|
26
|
+
others: KnownMachine[]
|
|
27
|
+
): BrowserMachine {
|
|
28
|
+
if (trust === "loopback") return { hostId: "local", how: "loopback" };
|
|
29
|
+
const addr = plainAddress(remote ?? "");
|
|
30
|
+
if (!addr || ["connect", "peer", "unknown"].includes(addr))
|
|
31
|
+
return { hostId: null, how: null };
|
|
32
|
+
if (own.includes(addr)) return { hostId: "local", how: "address" };
|
|
33
|
+
const hit = others.find((m) => m.addresses.includes(addr));
|
|
34
|
+
return hit
|
|
35
|
+
? { hostId: hit.hostId, how: "address" }
|
|
36
|
+
: { hostId: null, how: null };
|
|
37
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agents this machine can run and the models each offers, plus the
|
|
3
|
+
* Ollama models it serves and which agent runs each. The new-session form
|
|
4
|
+
* shows exactly this for the machine picked; a linked machine reports its
|
|
5
|
+
* own through GET /api/machine.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { AGENT_OPTIONS } from "../agent-options";
|
|
9
|
+
import { getProviderDefinition } from "../providers/registry";
|
|
10
|
+
import { probeAgents } from "../agents/probe";
|
|
11
|
+
import { discoveredModels } from "../chat/discovered-models";
|
|
12
|
+
import {
|
|
13
|
+
discoversModels,
|
|
14
|
+
getDefaultModelForAgent,
|
|
15
|
+
getModelOptions,
|
|
16
|
+
} from "../model-catalog";
|
|
17
|
+
import type { ChatModel } from "../chat/events";
|
|
18
|
+
import { ollamaModels } from "./ollama";
|
|
19
|
+
import type { CatalogAgent, MachineCatalog, OllamaEntry } from "./types";
|
|
20
|
+
|
|
21
|
+
const DISCOVER_MS = 6000;
|
|
22
|
+
// Agents that can run a model served by Ollama, in the order to prefer them.
|
|
23
|
+
const LOCAL_RUNNERS = ["opencode", "pi", "omp"] as const;
|
|
24
|
+
|
|
25
|
+
export const TASK_REMOTE_REASON =
|
|
26
|
+
"Tasks on another machine run Claude Code only";
|
|
27
|
+
|
|
28
|
+
const lastSegment = (v: string) => v.slice(v.lastIndexOf("/") + 1);
|
|
29
|
+
|
|
30
|
+
/** The runner that lists this Ollama tag among its models, if any. */
|
|
31
|
+
export function ollamaRunner(
|
|
32
|
+
tag: string,
|
|
33
|
+
byAgent: Record<string, ChatModel[]>
|
|
34
|
+
): OllamaEntry["runsWith"] {
|
|
35
|
+
for (const agent of LOCAL_RUNNERS) {
|
|
36
|
+
const models = byAgent[agent] ?? [];
|
|
37
|
+
const hit =
|
|
38
|
+
models.find((m) => m.value === `ollama/${tag}`) ??
|
|
39
|
+
models.find(
|
|
40
|
+
(m) =>
|
|
41
|
+
lastSegment(m.value) === tag &&
|
|
42
|
+
/ollama/i.test(`${m.value} ${m.description ?? ""}`)
|
|
43
|
+
);
|
|
44
|
+
if (hit) return { agent, model: hit.value };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function discover(agent: string): Promise<ChatModel[]> {
|
|
50
|
+
return Promise.race([
|
|
51
|
+
discoveredModels(agent, null),
|
|
52
|
+
new Promise<never>((_, reject) =>
|
|
53
|
+
setTimeout(() => reject(new Error("timed out")), DISCOVER_MS).unref?.()
|
|
54
|
+
),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function machineCatalog(): Promise<MachineCatalog> {
|
|
59
|
+
const [probes, tags] = await Promise.all([probeAgents(), ollamaModels()]);
|
|
60
|
+
const found: Record<string, ChatModel[]> = {};
|
|
61
|
+
const errors: Record<string, string> = {};
|
|
62
|
+
const wanted = AGENT_OPTIONS.filter(
|
|
63
|
+
(a) =>
|
|
64
|
+
probes[a.value]?.installed &&
|
|
65
|
+
(discoversModels(a.value) || LOCAL_RUNNERS.some((r) => r === a.value))
|
|
66
|
+
);
|
|
67
|
+
await Promise.all(
|
|
68
|
+
wanted.map(async (a) => {
|
|
69
|
+
if (!discoversModels(a.value)) return;
|
|
70
|
+
try {
|
|
71
|
+
found[a.value] = await discover(a.value);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
errors[a.value] = e instanceof Error ? e.message : String(e);
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
const agents: CatalogAgent[] = AGENT_OPTIONS.filter(
|
|
78
|
+
(a) => !getProviderDefinition(a.value)?.legacy || probes[a.value]?.installed
|
|
79
|
+
).map((a) => {
|
|
80
|
+
const base = getModelOptions(a.value);
|
|
81
|
+
return {
|
|
82
|
+
agent: a.value,
|
|
83
|
+
label: a.label,
|
|
84
|
+
installed: probes[a.value]?.installed ?? false,
|
|
85
|
+
auth: probes[a.value]?.auth ?? "unknown",
|
|
86
|
+
hint: probes[a.value]?.hint ?? null,
|
|
87
|
+
models: [...base, ...(found[a.value] ?? [])],
|
|
88
|
+
defaultModel: getDefaultModelForAgent(a.value),
|
|
89
|
+
taskBlocked: a.value === "claude" ? null : TASK_REMOTE_REASON,
|
|
90
|
+
modelsError: errors[a.value] ?? null,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
agents,
|
|
95
|
+
ollama: tags.map((name) => ({ name, runsWith: ollamaRunner(name, found) })),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This machine as it names itself: the one name every surface shows (the
|
|
3
|
+
* Machines dialog, the new-session chip, Work across machines), its OS and
|
|
4
|
+
* the addresses another device could reach it at.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import os from "os";
|
|
8
|
+
import { machineName } from "../claims/machine";
|
|
9
|
+
import { putSetting } from "../settings-store";
|
|
10
|
+
import { currentIdentity } from "../peer/keys";
|
|
11
|
+
import { lanAddresses, tailscaleAddresses } from "../security/net";
|
|
12
|
+
|
|
13
|
+
export interface MachineIdentity {
|
|
14
|
+
name: string;
|
|
15
|
+
hostname: string;
|
|
16
|
+
os: string;
|
|
17
|
+
arch: string;
|
|
18
|
+
connectMachineId: string | null;
|
|
19
|
+
addresses: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const OS_LABEL: Record<string, string> = {
|
|
23
|
+
darwin: "macOS",
|
|
24
|
+
linux: "Linux",
|
|
25
|
+
win32: "Windows",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const osLabel = (platform: string = os.platform()) =>
|
|
29
|
+
OS_LABEL[platform] ?? platform;
|
|
30
|
+
|
|
31
|
+
export function thisMachine(): MachineIdentity {
|
|
32
|
+
return {
|
|
33
|
+
name: machineName(),
|
|
34
|
+
hostname: os.hostname().split(".")[0] || "machine",
|
|
35
|
+
os: osLabel(),
|
|
36
|
+
arch: os.arch(),
|
|
37
|
+
connectMachineId: currentIdentity()?.machineId ?? null,
|
|
38
|
+
addresses: [...new Set([...tailscaleAddresses(), ...lanAddresses()])],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A user-set name for this machine; the same one Work across machines shows. */
|
|
43
|
+
export function setThisMachineName(raw: string): string {
|
|
44
|
+
const name = raw
|
|
45
|
+
.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim()
|
|
48
|
+
.slice(0, 60);
|
|
49
|
+
if (!name) throw new Error("Name is required");
|
|
50
|
+
putSetting("claims.machine_name", name);
|
|
51
|
+
return name;
|
|
52
|
+
}
|