@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,36 @@
|
|
|
1
|
+
/** How a machine is named on every screen, so they all agree. */
|
|
2
|
+
|
|
3
|
+
export interface Nameable {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const LOCAL_ID = "local";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "spark-54423f (host)" for the machine serving this dashboard, the plain name
|
|
12
|
+
* for the others; " · you're here" when the browser is on it.
|
|
13
|
+
*/
|
|
14
|
+
export function machineLabel(host: Nameable, browserHostId?: string | null) {
|
|
15
|
+
const base = host.id === LOCAL_ID ? `${host.name} (host)` : host.name;
|
|
16
|
+
return browserHostId && browserHostId === host.id
|
|
17
|
+
? `${base} · you're here`
|
|
18
|
+
: base;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a machine reports about itself, as one line. */
|
|
22
|
+
export function machineDetail(h: {
|
|
23
|
+
name?: string;
|
|
24
|
+
hostname?: string | null;
|
|
25
|
+
os?: string | null;
|
|
26
|
+
arch?: string | null;
|
|
27
|
+
}): string {
|
|
28
|
+
// Its hostname is only news when the name isn't already it.
|
|
29
|
+
const hostname =
|
|
30
|
+
h.hostname && h.hostname.toLowerCase() !== h.name?.toLowerCase()
|
|
31
|
+
? h.hostname
|
|
32
|
+
: null;
|
|
33
|
+
return [hostname, [h.os, h.arch].filter(Boolean).join(" ")]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(" · ");
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The models a local Ollama serves, asked of its own API. */
|
|
2
|
+
|
|
3
|
+
const DEFAULT_URL = "http://127.0.0.1:11434";
|
|
4
|
+
const TIMEOUT_MS = 1500;
|
|
5
|
+
|
|
6
|
+
export function ollamaBase(
|
|
7
|
+
env: Record<string, string | undefined> = process.env
|
|
8
|
+
): string {
|
|
9
|
+
const raw = env.OLLAMA_HOST?.trim();
|
|
10
|
+
if (!raw) return DEFAULT_URL;
|
|
11
|
+
const url = /^https?:\/\//.test(raw) ? raw : `http://${raw}`;
|
|
12
|
+
return url.replace(/\/+$/, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Model tags, or none when Ollama isn't running or doesn't answer in time. */
|
|
16
|
+
export async function ollamaModels(
|
|
17
|
+
fetchImpl: typeof fetch = fetch,
|
|
18
|
+
base = ollamaBase()
|
|
19
|
+
): Promise<string[]> {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetchImpl(`${base}/api/tags`, {
|
|
22
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) return [];
|
|
25
|
+
const body = (await res.json()) as { models?: { name?: unknown }[] };
|
|
26
|
+
return (body.models ?? [])
|
|
27
|
+
.map((m) => (typeof m.name === "string" ? m.name : ""))
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a linked machine says about itself. The identity is kept in
|
|
3
|
+
* host_info so its name, OS and addresses show while it is offline; the
|
|
4
|
+
* catalog (agents and models) is only held in memory for a minute.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getDb } from "../db";
|
|
8
|
+
import { hostApi, hostLink, linkedHostIds } from "../hosts/remote-api";
|
|
9
|
+
import { isRemoteHost } from "../hosts";
|
|
10
|
+
import type { HostCatalogResponse, MachineReport } from "./types";
|
|
11
|
+
|
|
12
|
+
const CATALOG_TTL_MS = 60_000;
|
|
13
|
+
const INFO_TTL_MS = 5 * 60_000;
|
|
14
|
+
|
|
15
|
+
interface Cached {
|
|
16
|
+
at: number;
|
|
17
|
+
report: MachineReport;
|
|
18
|
+
}
|
|
19
|
+
const cache = new Map<string, Cached>();
|
|
20
|
+
const running = new Map<string, Promise<MachineReport>>();
|
|
21
|
+
const tried = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
export interface HostInfo {
|
|
24
|
+
hostId: string;
|
|
25
|
+
name: string | null;
|
|
26
|
+
hostname: string | null;
|
|
27
|
+
os: string | null;
|
|
28
|
+
arch: string | null;
|
|
29
|
+
connectMachineId: string | null;
|
|
30
|
+
addresses: string[];
|
|
31
|
+
fetchedAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface InfoRow {
|
|
35
|
+
host_id: string;
|
|
36
|
+
name: string | null;
|
|
37
|
+
hostname: string | null;
|
|
38
|
+
os: string | null;
|
|
39
|
+
arch: string | null;
|
|
40
|
+
connect_machine_id: string | null;
|
|
41
|
+
addresses: string;
|
|
42
|
+
fetched_at: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parseAddresses = (raw: string): string[] => {
|
|
46
|
+
try {
|
|
47
|
+
const v = JSON.parse(raw);
|
|
48
|
+
return Array.isArray(v) ? v.filter((a) => typeof a === "string") : [];
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function allHostInfo(): HostInfo[] {
|
|
55
|
+
return (getDb().prepare(`SELECT * FROM host_info`).all() as InfoRow[]).map(
|
|
56
|
+
(r) => ({
|
|
57
|
+
hostId: r.host_id,
|
|
58
|
+
name: r.name,
|
|
59
|
+
hostname: r.hostname,
|
|
60
|
+
os: r.os,
|
|
61
|
+
arch: r.arch,
|
|
62
|
+
connectMachineId: r.connect_machine_id,
|
|
63
|
+
addresses: parseAddresses(r.addresses),
|
|
64
|
+
fetchedAt: r.fetched_at,
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const hostInfo = (hostId: string): HostInfo | null =>
|
|
70
|
+
allHostInfo().find((i) => i.hostId === hostId) ?? null;
|
|
71
|
+
|
|
72
|
+
function save(hostId: string, report: MachineReport, now: number): void {
|
|
73
|
+
const i = report.identity;
|
|
74
|
+
getDb()
|
|
75
|
+
.prepare(
|
|
76
|
+
`INSERT INTO host_info (host_id, name, hostname, os, arch, connect_machine_id, addresses, fetched_at)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
78
|
+
ON CONFLICT(host_id) DO UPDATE SET name = excluded.name,
|
|
79
|
+
hostname = excluded.hostname, os = excluded.os, arch = excluded.arch,
|
|
80
|
+
connect_machine_id = excluded.connect_machine_id,
|
|
81
|
+
addresses = excluded.addresses, fetched_at = excluded.fetched_at`
|
|
82
|
+
)
|
|
83
|
+
.run(
|
|
84
|
+
hostId,
|
|
85
|
+
i.name,
|
|
86
|
+
i.hostname,
|
|
87
|
+
i.os,
|
|
88
|
+
i.arch,
|
|
89
|
+
i.connectMachineId,
|
|
90
|
+
JSON.stringify(i.addresses),
|
|
91
|
+
now
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const text = (v: unknown, max = 120) =>
|
|
96
|
+
typeof v === "string" ? v.slice(0, max) : "";
|
|
97
|
+
|
|
98
|
+
/** A peer's report, trimmed to the shape we use; anything else is refused. */
|
|
99
|
+
export function parseReport(body: unknown): MachineReport | null {
|
|
100
|
+
const b = body as Partial<MachineReport> | null;
|
|
101
|
+
if (!b?.identity || !b.catalog || !Array.isArray(b.catalog.agents))
|
|
102
|
+
return null;
|
|
103
|
+
const i = b.identity;
|
|
104
|
+
return {
|
|
105
|
+
identity: {
|
|
106
|
+
name: text(i.name, 60),
|
|
107
|
+
hostname: text(i.hostname),
|
|
108
|
+
os: text(i.os, 30),
|
|
109
|
+
arch: text(i.arch, 30),
|
|
110
|
+
connectMachineId: text(i.connectMachineId) || null,
|
|
111
|
+
addresses: (Array.isArray(i.addresses) ? i.addresses : [])
|
|
112
|
+
.filter((a): a is string => typeof a === "string")
|
|
113
|
+
.slice(0, 16)
|
|
114
|
+
.map((a) => a.slice(0, 64)),
|
|
115
|
+
},
|
|
116
|
+
catalog: {
|
|
117
|
+
agents: b.catalog.agents.slice(0, 40),
|
|
118
|
+
ollama: Array.isArray(b.catalog.ollama)
|
|
119
|
+
? b.catalog.ollama.slice(0, 200)
|
|
120
|
+
: [],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function fetchHostMachine(
|
|
126
|
+
hostId: string,
|
|
127
|
+
now = Date.now()
|
|
128
|
+
): Promise<MachineReport> {
|
|
129
|
+
const hit = cache.get(hostId);
|
|
130
|
+
if (hit && now - hit.at < CATALOG_TTL_MS) return hit.report;
|
|
131
|
+
const link = hostLink(hostId);
|
|
132
|
+
if (!link) throw new Error("This machine isn't linked");
|
|
133
|
+
const wait = running.get(hostId);
|
|
134
|
+
if (wait) return wait;
|
|
135
|
+
const p = hostApi<unknown>(link, "/api/machine", { timeout: 12_000 })
|
|
136
|
+
.then((body) => {
|
|
137
|
+
const report = parseReport(body);
|
|
138
|
+
if (!report)
|
|
139
|
+
throw new Error("Its AgentOS answered in a format we can't read");
|
|
140
|
+
cache.set(hostId, { at: now, report });
|
|
141
|
+
save(hostId, report, now);
|
|
142
|
+
return report;
|
|
143
|
+
})
|
|
144
|
+
.finally(() => running.delete(hostId));
|
|
145
|
+
running.set(hostId, p);
|
|
146
|
+
return p;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The agents of a linked machine, or why they can't be listed. */
|
|
150
|
+
export async function hostCatalog(
|
|
151
|
+
hostId: string
|
|
152
|
+
): Promise<HostCatalogResponse> {
|
|
153
|
+
if (!isRemoteHost(hostId))
|
|
154
|
+
return { catalog: null, error: "Not a linked machine" };
|
|
155
|
+
try {
|
|
156
|
+
return { catalog: (await fetchHostMachine(hostId)).catalog, error: null };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
const old = /404|not found|forbidden|not allowed/i.test(String(e));
|
|
159
|
+
return {
|
|
160
|
+
catalog: null,
|
|
161
|
+
error: old
|
|
162
|
+
? "Update AgentOS on that machine to see its agents and models"
|
|
163
|
+
: e instanceof Error
|
|
164
|
+
? e.message
|
|
165
|
+
: String(e),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Read every linked machine whose identity is old; failures are ignored. */
|
|
171
|
+
export function refreshHostInfo(now = Date.now()): void {
|
|
172
|
+
const have = new Map(allHostInfo().map((i) => [i.hostId, i.fetchedAt]));
|
|
173
|
+
for (const hostId of linkedHostIds()) {
|
|
174
|
+
if (now - (have.get(hostId) ?? 0) < INFO_TTL_MS) continue;
|
|
175
|
+
if (now - (tried.get(hostId) ?? 0) < INFO_TTL_MS) continue;
|
|
176
|
+
tried.set(hostId, now);
|
|
177
|
+
fetchHostMachine(hostId, now).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { thisMachine } from "./identity";
|
|
2
|
+
import { machineCatalog } from "./catalog";
|
|
3
|
+
import type { MachineReport } from "./types";
|
|
4
|
+
|
|
5
|
+
/** What GET /api/machine answers: this machine's identity and agents. */
|
|
6
|
+
export async function thisMachineReport(): Promise<MachineReport> {
|
|
7
|
+
return { identity: thisMachine(), catalog: await machineCatalog() };
|
|
8
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** What a machine reports about itself, shared by the server and the UI. */
|
|
2
|
+
|
|
3
|
+
import type { AgentType } from "../providers";
|
|
4
|
+
import type { AgentAuth } from "../agents/probe";
|
|
5
|
+
|
|
6
|
+
export interface CatalogModel {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CatalogAgent {
|
|
13
|
+
agent: AgentType;
|
|
14
|
+
label: string;
|
|
15
|
+
installed: boolean;
|
|
16
|
+
auth: AgentAuth;
|
|
17
|
+
hint: string | null;
|
|
18
|
+
models: CatalogModel[];
|
|
19
|
+
defaultModel: string;
|
|
20
|
+
/** Why it can't run a task on a linked machine; null when it can. */
|
|
21
|
+
taskBlocked: string | null;
|
|
22
|
+
/** Set when asking the agent for its models failed. */
|
|
23
|
+
modelsError: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OllamaEntry {
|
|
27
|
+
/** The tag Ollama calls it, e.g. "qwen3:27b". */
|
|
28
|
+
name: string;
|
|
29
|
+
/** The agent and model value that runs it here; null when none can. */
|
|
30
|
+
runsWith: { agent: AgentType; model: string } | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MachineCatalog {
|
|
34
|
+
agents: CatalogAgent[];
|
|
35
|
+
ollama: OllamaEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MachineReport {
|
|
39
|
+
identity: {
|
|
40
|
+
name: string;
|
|
41
|
+
hostname: string;
|
|
42
|
+
os: string;
|
|
43
|
+
arch: string;
|
|
44
|
+
connectMachineId: string | null;
|
|
45
|
+
addresses: string[];
|
|
46
|
+
};
|
|
47
|
+
catalog: MachineCatalog;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One machine's agents as the new-session form asks for them. */
|
|
51
|
+
export interface HostCatalogResponse {
|
|
52
|
+
catalog: MachineCatalog | null;
|
|
53
|
+
/** Why there is no catalog (unlinked, unreachable, older AgentOS). */
|
|
54
|
+
error: string | null;
|
|
55
|
+
}
|
package/lib/peer/allowlist.ts
CHANGED
|
@@ -58,6 +58,8 @@ export const PEER_ROUTES: readonly PeerRoute[] = [
|
|
|
58
58
|
{ method: "GET", pattern: "/api/projects" },
|
|
59
59
|
{ method: "POST", pattern: "/api/projects/availability" },
|
|
60
60
|
{ method: "GET", pattern: "/api/tmux/discover" },
|
|
61
|
+
// Its name, OS, addresses, agents and models, for the new-session form.
|
|
62
|
+
{ method: "GET", pattern: "/api/machine" },
|
|
61
63
|
{ method: "GET", pattern: "/api/load" },
|
|
62
64
|
|
|
63
65
|
{ method: "GET", pattern: "/api/worktree-sweep" },
|
package/lib/peer/machines.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface AccountMachine {
|
|
|
17
17
|
/** The Machines host it's linked as, when it is. */
|
|
18
18
|
hostId: string | null;
|
|
19
19
|
linkState: "active" | "suspended" | null;
|
|
20
|
+
/** How it's linked: Connect, or the Tailscale/ssh link in Machines. */
|
|
21
|
+
linkedVia: "connect" | "tailscale" | null;
|
|
20
22
|
suspendedReason: string | null;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -54,6 +56,28 @@ export async function accountMachines(
|
|
|
54
56
|
}[]
|
|
55
57
|
).map((r) => [r.machine_id, r])
|
|
56
58
|
);
|
|
59
|
+
// Machines linked the other way: AgentOS to AgentOS over Tailscale or ssh.
|
|
60
|
+
const tokenLinked = (
|
|
61
|
+
getDb()
|
|
62
|
+
.prepare(
|
|
63
|
+
`SELECT h.id, h.name, i.name AS reported, i.connect_machine_id AS machine_id
|
|
64
|
+
FROM host_links l JOIN hosts h ON h.id = l.host_id
|
|
65
|
+
LEFT JOIN host_info i ON i.host_id = h.id WHERE h.kind != 'connect'`
|
|
66
|
+
)
|
|
67
|
+
.all() as {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
reported: string | null;
|
|
71
|
+
machine_id: string | null;
|
|
72
|
+
}[]
|
|
73
|
+
).map((r) => ({ ...r, names: [r.name, r.reported] }));
|
|
74
|
+
const sameMachine = (id: string, name: string) =>
|
|
75
|
+
tokenLinked.find(
|
|
76
|
+
(t) =>
|
|
77
|
+
t.machine_id === id ||
|
|
78
|
+
(!t.machine_id &&
|
|
79
|
+
t.names.some((n) => n && n.toLowerCase() === name.toLowerCase()))
|
|
80
|
+
);
|
|
57
81
|
return {
|
|
58
82
|
enrolled: view.enrolled,
|
|
59
83
|
entitlements: view.entitlements,
|
|
@@ -62,6 +86,7 @@ export async function accountMachines(
|
|
|
62
86
|
uncheckedSince: view.uncheckedSince,
|
|
63
87
|
machines: view.peers.map((p) => {
|
|
64
88
|
const pin = pins.get(p.id);
|
|
89
|
+
const other = pin ? undefined : sameMachine(p.id, p.name);
|
|
65
90
|
const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
|
|
66
91
|
return {
|
|
67
92
|
id: p.id,
|
|
@@ -70,12 +95,15 @@ export async function accountMachines(
|
|
|
70
95
|
online: Number.isFinite(seen) && now - seen < ONLINE_MS,
|
|
71
96
|
lastSeenAt: p.lastSeenAt,
|
|
72
97
|
version: p.version,
|
|
73
|
-
hostId: pin?.host_id ?? null,
|
|
98
|
+
hostId: pin?.host_id ?? other?.id ?? null,
|
|
74
99
|
linkState: pin
|
|
75
100
|
? pin.state === "active"
|
|
76
101
|
? "active"
|
|
77
102
|
: "suspended"
|
|
78
|
-
:
|
|
103
|
+
: other
|
|
104
|
+
? "active"
|
|
105
|
+
: null,
|
|
106
|
+
linkedVia: pin ? "connect" : other ? "tailscale" : null,
|
|
79
107
|
suspendedReason:
|
|
80
108
|
pin && pin.state !== "active" ? pin.suspended_reason : null,
|
|
81
109
|
};
|
package/lib/sidebar/machines.ts
CHANGED
|
@@ -54,3 +54,13 @@ export function machineGroups(
|
|
|
54
54
|
.filter((g) => g.rows.length > 0)
|
|
55
55
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Whether a session belongs to the machine the sidebar is narrowed to. */
|
|
59
|
+
export function inMachineScope(
|
|
60
|
+
session: Pick<Session, "host_id">,
|
|
61
|
+
scope: string | null
|
|
62
|
+
): boolean {
|
|
63
|
+
if (!scope) return true;
|
|
64
|
+
const host = session.host_id || "local";
|
|
65
|
+
return host === scope;
|
|
66
|
+
}
|
package/lib/start-options.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getProviderDefinition } from "./providers/registry";
|
|
|
10
10
|
import { probeAgents } from "./agents/probe";
|
|
11
11
|
import { gitCheck } from "./git";
|
|
12
12
|
import { listHosts } from "./hosts";
|
|
13
|
+
import { hostCatalog } from "./machines/remote";
|
|
13
14
|
import { projectMachines } from "./hosts/project-machines";
|
|
14
15
|
import { getModelOptions, getDefaultModelForAgent } from "./model-catalog";
|
|
15
16
|
import { effectiveModels } from "./models/config";
|
|
@@ -52,6 +53,10 @@ export async function startOptions(
|
|
|
52
53
|
planError = error instanceof Error ? error.message : String(error);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// A project on another machine shows that machine's agents, as it reports them.
|
|
57
|
+
const there = here
|
|
58
|
+
? null
|
|
59
|
+
: (await hostCatalog(project?.host_id ?? "")).catalog;
|
|
55
60
|
const [probes, git, machines] = await Promise.all([
|
|
56
61
|
here ? probeAgents() : Promise.resolve(null),
|
|
57
62
|
project && here
|
|
@@ -78,10 +83,21 @@ export async function startOptions(
|
|
|
78
83
|
).map((a) => ({
|
|
79
84
|
value: a.value,
|
|
80
85
|
label: a.label,
|
|
81
|
-
installed:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
installed:
|
|
87
|
+
probes?.[a.value]?.installed ??
|
|
88
|
+
there?.agents.find((t) => t.agent === a.value)?.installed ??
|
|
89
|
+
null,
|
|
90
|
+
auth:
|
|
91
|
+
probes?.[a.value]?.auth ??
|
|
92
|
+
there?.agents.find((t) => t.agent === a.value)?.auth ??
|
|
93
|
+
null,
|
|
94
|
+
hint:
|
|
95
|
+
probes?.[a.value]?.hint ??
|
|
96
|
+
there?.agents.find((t) => t.agent === a.value)?.hint ??
|
|
97
|
+
null,
|
|
98
|
+
models:
|
|
99
|
+
there?.agents.find((t) => t.agent === a.value)?.models ??
|
|
100
|
+
getModelOptions(a.value),
|
|
85
101
|
defaultModel: getDefaultModelForAgent(a.value),
|
|
86
102
|
})),
|
|
87
103
|
taskModels: modelChoices(models),
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
|
+
import { sessionPath } from "../agents/tool-path";
|
|
10
11
|
|
|
11
12
|
export interface PackageManager {
|
|
12
13
|
name: "bun" | "pnpm" | "yarn" | "npm";
|
|
@@ -67,12 +68,14 @@ export function shellCommand(command: string): string {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// What setup commands run under: the server's environment without the
|
|
70
|
-
// settings that make an install skip devDependencies
|
|
71
|
+
// settings that make an install skip devDependencies, on the PATH a session
|
|
72
|
+
// gets (version managers' shims first), so `bundle` isn't the system Ruby's.
|
|
71
73
|
export function setupEnv(
|
|
72
74
|
base: NodeJS.ProcessEnv,
|
|
73
75
|
extra: Record<string, string> = {}
|
|
74
76
|
): NodeJS.ProcessEnv {
|
|
75
77
|
const env: NodeJS.ProcessEnv = { ...base, ...extra };
|
|
78
|
+
if (!extra.PATH) env.PATH = sessionPath(base.PATH);
|
|
76
79
|
for (const key of Object.keys(env)) {
|
|
77
80
|
const k = key.toLowerCase();
|
|
78
81
|
if (
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { proxy, subscribe } from "valtio";
|
|
2
|
+
|
|
3
|
+
const KEY = "agentOS-machines-v1";
|
|
4
|
+
|
|
5
|
+
// What this browser remembers about machines: which one it is on (when the
|
|
6
|
+
// server couldn't tell) and which machine the sidebar is narrowed to.
|
|
7
|
+
interface Remembered {
|
|
8
|
+
browserHostId: string | null;
|
|
9
|
+
scopeHostId: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const machinesUi = proxy<Remembered & { hydrated: boolean }>({
|
|
13
|
+
browserHostId: null,
|
|
14
|
+
scopeHostId: null,
|
|
15
|
+
hydrated: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const machinesUiActions = {
|
|
19
|
+
hydrate: () => {
|
|
20
|
+
if (machinesUi.hydrated || typeof window === "undefined") return;
|
|
21
|
+
try {
|
|
22
|
+
const saved = JSON.parse(
|
|
23
|
+
localStorage.getItem(KEY) ?? "{}"
|
|
24
|
+
) as Partial<Remembered>;
|
|
25
|
+
machinesUi.browserHostId = saved.browserHostId ?? null;
|
|
26
|
+
machinesUi.scopeHostId = saved.scopeHostId ?? null;
|
|
27
|
+
} catch {
|
|
28
|
+
// A bad entry is the same as none.
|
|
29
|
+
}
|
|
30
|
+
machinesUi.hydrated = true;
|
|
31
|
+
subscribe(machinesUi, () => {
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem(
|
|
34
|
+
KEY,
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
browserHostId: machinesUi.browserHostId,
|
|
37
|
+
scopeHostId: machinesUi.scopeHostId,
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
// Private mode: it just won't be remembered.
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
setBrowserHost: (hostId: string | null) => {
|
|
46
|
+
machinesUi.browserHostId = hostId;
|
|
47
|
+
},
|
|
48
|
+
setScope: (hostId: string | null) => {
|
|
49
|
+
machinesUi.scopeHostId = hostId;
|
|
50
|
+
},
|
|
51
|
+
};
|