Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 107 additions & 15 deletions packages/launcher/src/main/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ const LAUNCHER_SESSIONS_DIR = path.join(CONFIG_DIR, "launcher-sessions")
const DEFAULT_CHAT_CHANNEL = "main"
const CHAT_POLL_INTERVAL_MS = 2500

interface LauncherSettingsStore {
get(key?: string): unknown
}

function normalizeWorkspaceEndpoint(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const raw = value.trim()
if (!raw) return undefined
try {
const url = new URL(raw)
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
if (url.hostname === "workspace.openagents.org") {
return url.origin.replace("workspace.openagents.org", "workspace-endpoint.openagents.org")
}
return url.origin
} catch {
return undefined
}
}

export interface InstalledAgentRecord {
name: string
version: string | null
Expand Down Expand Up @@ -258,9 +278,10 @@ function extractToolCalls(msg: ChatMessage): ChatToolCall[] | undefined {
export function extractMentions(text: string): string[] {
const out: string[] = []
const re = /(^|\s)@([a-zA-Z0-9_-]+)/g
let m
while ((m = re.exec(text)) !== null) {
if (!out.includes(m[2])) out.push(m[2])
let match = re.exec(text)
while (match !== null) {
if (!out.includes(match[2])) out.push(match[2])
match = re.exec(text)
}
return out
}
Expand Down Expand Up @@ -374,7 +395,7 @@ function resolveWorkingNode(
let core: Record<string, unknown> | null = loadCore()

export class AgentManager extends EventEmitter {
private _store: unknown
private _store: LauncherSettingsStore
private _healthByType = new Map<string, unknown>()
private _healthRefreshInFlight = new Set<string>()
private _lastHealthRefreshAt = 0
Expand Down Expand Up @@ -409,18 +430,32 @@ export class AgentManager extends EventEmitter {
private _chatPolls = new Map<string, ChatPollingState>()
_connector: Record<string, unknown> | null = null

constructor(store: unknown) {
constructor(store: LauncherSettingsStore) {
super()
this._store = store
if (!core) core = loadCore()
if (core) {
const AgentConnector = (core as Record<string, unknown>)
.AgentConnector as new (opts: unknown) => Record<string, unknown>
this._connector = new AgentConnector({ configDir: CONFIG_DIR })
this._connector = this.createConnector()
}
ensureDir(LAUNCHER_SESSIONS_DIR)
}

private createConnector(): Record<string, unknown> {
const AgentConnector = (core as Record<string, unknown>)
.AgentConnector as new (opts: unknown) => Record<string, unknown>
const workspaceEndpoint = normalizeWorkspaceEndpoint(
this._store.get("workspaceEndpoint"),
)
return new AgentConnector({
configDir: CONFIG_DIR,
...(workspaceEndpoint ? { workspaceEndpoint } : {}),
})
}

private configuredWorkspaceEndpoint(): string | undefined {
return normalizeWorkspaceEndpoint(this._store.get("workspaceEndpoint"))
}

getSupportedAgentTypes(): string[] {
const supported = (core as Record<string, unknown> | null)?.adapters
? Object.keys(
Expand Down Expand Up @@ -451,9 +486,7 @@ export class AgentManager extends EventEmitter {
for (const k of cacheKeys) delete require.cache[k]
core = loadCore()
if (core) {
const AgentConnector = (core as Record<string, unknown>)
.AgentConnector as new (opts: unknown) => Record<string, unknown>
this._connector = new AgentConnector({ configDir: CONFIG_DIR })
this._connector = this.createConnector()
}
this.clearCatalogCache()
this._agentsCache = { value: [], at: 0 }
Expand Down Expand Up @@ -806,6 +839,25 @@ export class AgentManager extends EventEmitter {
})
}

private parseCustomWorkspaceUrl(
urlStr: string,
): { endpoint?: string; slug?: string; token?: string } | null {
try {
const u = new URL(urlStr.trim())
if (u.protocol !== "http:" && u.protocol !== "https:") return null
const host = u.hostname.toLowerCase()
if (host === "workspace.openagents.org") {
return null
}
const endpoint = u.origin
const slug = u.pathname.replace(/^\//, "").split("/")[0] || undefined
const token = u.searchParams.get("token") || undefined
return { endpoint, slug, token }
} catch {
return null
}
}

async registerWorkspaceFromToken(input: {
url?: string
token?: string
Expand All @@ -820,6 +872,38 @@ export class AgentManager extends EventEmitter {
const tokenOrSlug = (input.token || input.slug || input.url || "").trim()
if (!tokenOrSlug) throw new Error("Missing workspace URL or token")

const customParsed = input.url ? this.parseCustomWorkspaceUrl(input.url) : null
if (customParsed) {
const slug = input.slug || customParsed.slug
const token = input.token || customParsed.token
if (!slug)
throw new Error(
"Custom workspace URL must include slug (first path segment) or provide slug explicitly",
)
if (!token)
throw new Error(
"Custom workspace URL must include token query parameter or provide token explicitly",
)

const config = this._connector!.config as Record<string, unknown>
const addNetwork = config.addNetwork as (opts: unknown) => unknown
addNetwork.call(config, {
id: slug,
slug,
name: slug,
endpoint: customParsed.endpoint,
token,
})
this.signalReload()
return {
id: slug,
slug,
name: slug,
endpoint: customParsed.endpoint,
token,
}
}

const resolveToken = this._connector!.resolveToken as (
token: string,
) => Promise<{
Expand All @@ -831,22 +915,23 @@ export class AgentManager extends EventEmitter {
const info = await resolveToken.call(this._connector, tokenOrSlug)
const slug = info.slug || info.workspace_id || input.slug
if (!slug) throw new Error("Could not resolve workspace from input")
const endpoint = info.endpoint || this.configuredWorkspaceEndpoint()

const config = this._connector!.config as Record<string, unknown>
const addNetwork = config.addNetwork as (opts: unknown) => unknown
addNetwork.call(config, {
id: info.workspace_id,
slug,
name: info.name || slug,
endpoint: info.endpoint,
endpoint,
token: input.token || tokenOrSlug,
})
this.signalReload()
return {
id: info.workspace_id,
slug,
name: info.name || slug,
endpoint: info.endpoint,
endpoint,
token: input.token || tokenOrSlug,
}
}
Expand All @@ -867,14 +952,15 @@ export class AgentManager extends EventEmitter {
const info = await resolveToken.call(this._connector, tokenOrSlug)
const slug = info.slug || info.workspace_id
const wsName = info.name || slug
const endpoint = info.endpoint || this.configuredWorkspaceEndpoint()

const addNetwork = (this._connector!.config as Record<string, unknown>)
.addNetwork as (opts: unknown) => void
addNetwork.call(this._connector!.config as Record<string, unknown>, {
id: info.workspace_id,
slug,
name: wsName,
endpoint: info.endpoint,
endpoint,
token: tokenOrSlug,
})

Expand All @@ -883,7 +969,13 @@ export class AgentManager extends EventEmitter {
slug: string,
) => void
connectWorkspace.call(this._connector, agentName, slug as string)
} catch {
} catch (err) {
const networks = this.getNetworks() as Array<{ id?: string; slug?: string }>
const existing = networks.some(
(network) => network.slug === tokenOrSlug || network.id === tokenOrSlug,
)
if (!existing) throw err

const connectWorkspace = this._connector!.connectWorkspace as (
name: string,
slug: string,
Expand Down
7 changes: 6 additions & 1 deletion packages/launcher/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,7 +1415,12 @@ function setupIPC(): void {
)

ipcMain.handle("settings:get", (_e, key) => store.get(key))
ipcMain.handle("settings:set", (_e, key, value) => store.set(key, value))
ipcMain.handle("settings:set", (_e, key, value) => {
store.set(key, value)
if (key === "workspaceEndpoint" && agentManager) {
agentManager.reloadCore()
}
})

// ── Connections ──
ipcMain.handle("connections:list", () => connectionsStore.list())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./WorkspaceRecentActivity"
import { platformLabel } from "../connections/platforms"
import type { Agent, Workspace } from "../../types"
import { workspaceDisplayHost } from "../../lib/workspace-urls"

export interface WorkspaceCardData {
ws: Workspace
Expand Down Expand Up @@ -72,7 +73,7 @@ export function WorkspaceCard({
<WorkspaceHealth state={health} />
</div>
<div className="text-[11px] text-(--text-tertiary) truncate">
workspace.openagents.org/{slug}
{workspaceDisplayHost(ws.endpoint)}/{slug}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,31 +57,39 @@ export function WorkspaceQuickConnect({
}
}, [open])

const parseInput = (raw: string): { slug?: string; token?: string } => {
const parseInput = (
raw: string,
): { url?: string; slug?: string; token?: string; customUrl?: boolean } => {
const v = raw.trim()
if (!v) return {}
try {
const u = new URL(v)
const slug = u.pathname.replace(/^\//, "").split("/")[0] || undefined
const token = u.searchParams.get("token") || undefined
if (slug || token) return { slug, token }
return {
url: v,
slug,
token,
customUrl: u.hostname.toLowerCase() !== "workspace.openagents.org",
}
} catch {}
return { token: v }
}

const handlePasteConnect = async (): Promise<void> => {
const { slug, token } = parseInput(pasted)
if (!slug && !token) {
const parsed = parseInput(pasted)
const { slug, token } = parsed
if (!parsed.url && !slug && !token) {
showToast("Paste a workspace URL or token", "warning")
return
}
setBusy(true)
try {
const ws = await window.api.registerWorkspaceFromToken({
url: pasted.trim() || undefined,
token,
slug,
})
const ws = await window.api.registerWorkspaceFromToken(
parsed.customUrl
? { url: parsed.url }
: { url: parsed.url, token, slug },
)
const label = ws.name || ws.slug || slug || "workspace"
showToast(
`Registered ${label}. Open Agents tab to connect an agent.`,
Expand Down Expand Up @@ -157,11 +165,11 @@ export function WorkspaceQuickConnect({
<Input
value={pasted}
onChange={(e) => setPasted(e.target.value)}
placeholder="https://workspace.openagents.org/my-team?token=…"
placeholder="https://workspace.openagents.org/my-team?token=… or http://localhost:8000/my-team?token=…"
autoFocus
/>
<div className="text-[11px] text-(--text-tertiary) mt-1.5">
Token and slug are extracted automatically.
Hosted URLs use remote token resolution. Custom URLs use the URL origin, first path segment, and token query parameter.
</div>
</div>
</div>
Expand Down Expand Up @@ -211,7 +219,7 @@ export function WorkspaceQuickConnect({
"Copy the workspace URL and paste it in the “Paste URL / token” tab.",
].map((step, i) => (
<li
key={i}
key={step}
className="flex items-start gap-2.5 text-[11px] text-(--text-secondary) leading-relaxed"
>
<span className="shrink-0 w-4.5 h-4.5 rounded-full bg-(--bg-input) text-(--text-primary) text-[10px] font-semibold flex items-center justify-center">
Expand Down
15 changes: 15 additions & 0 deletions packages/launcher/src/renderer/lib/workspace-urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const DEFAULT_WORKSPACE_WEB_BASE_URL = "https://workspace.openagents.org"

export function workspaceWebBaseUrl(endpoint?: string): string {
const baseUrl = (endpoint || DEFAULT_WORKSPACE_WEB_BASE_URL).replace(/\/$/, "")
return baseUrl.replace("workspace-endpoint", "workspace").replace(/\/v1$/, "")
}

export function workspaceDisplayHost(endpoint?: string): string {
const baseUrl = workspaceWebBaseUrl(endpoint)
try {
return new URL(baseUrl).host
} catch {
return baseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")
}
}
Loading
Loading