@runuai/host 0.8.6 → 0.8.8
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/lib/agents/cursor.ts +1 -0
- package/lib/mcp-gateway.ts +43 -10
- package/lib/orchestrator.ts +46 -2
- package/package.json +3 -3
- package/src/main.ts +10 -0
- package/src/protocol.ts +3 -0
- package/src/ui/server.ts +31 -0
- package/ui/app.js +24 -1
- package/ui/index.html +2 -2
- package/ui/style.css +12 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-wheel.svg +9 -0
package/lib/agents/cursor.ts
CHANGED
|
@@ -212,6 +212,7 @@ export class CursorSession implements AgentSession {
|
|
|
212
212
|
"--stream-partial-output",
|
|
213
213
|
"--force", // container is the sandbox — auto-run tools
|
|
214
214
|
"--trust", // skip the workspace-trust prompt in headless
|
|
215
|
+
"--approve-mcps", // load the gateway MCP servers from ~/.cursor/mcp.json (ADR-057)
|
|
215
216
|
);
|
|
216
217
|
if (this.model && this.model !== "auto") args.push("-m", this.model);
|
|
217
218
|
if (this.sessionId) args.push("--resume", this.sessionId);
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -270,16 +270,20 @@ export function startMcpGateway(): void {
|
|
|
270
270
|
|
|
271
271
|
// --- task container wiring (ADR-057 task-up writers) -------------------------
|
|
272
272
|
|
|
273
|
-
/** Idempotent node -e merge of entries into
|
|
273
|
+
/** Idempotent node -e merge of entries (argv[2]) into an mcpServers file
|
|
274
|
+
* (argv[1]) — reused for Claude's /workspace/.mcp.json and Cursor's
|
|
275
|
+
* ~/.cursor/mcp.json. Creates the parent dir when missing. */
|
|
274
276
|
const MERGE_MCP_JSON = `
|
|
275
277
|
const fs = require("fs");
|
|
276
|
-
const
|
|
278
|
+
const path = require("path");
|
|
279
|
+
const p = process.argv[1];
|
|
280
|
+
try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch {}
|
|
277
281
|
let j = {};
|
|
278
282
|
let existed = true;
|
|
279
283
|
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
|
|
280
284
|
j.mcpServers = j.mcpServers || {};
|
|
281
285
|
let changed = false;
|
|
282
|
-
for (const [k, v] of Object.entries(JSON.parse(process.argv[
|
|
286
|
+
for (const [k, v] of Object.entries(JSON.parse(process.argv[2]))) {
|
|
283
287
|
if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
|
|
284
288
|
}
|
|
285
289
|
if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
|
|
@@ -290,16 +294,21 @@ function shellQuote(value: string): string {
|
|
|
290
294
|
}
|
|
291
295
|
|
|
292
296
|
/**
|
|
293
|
-
* Write the task's MCP configs inside the container
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
+
* Write the task's MCP configs inside the container, one shape per engine on
|
|
298
|
+
* the roster — all pointing at the same host gateway URLs:
|
|
299
|
+
* - Claude — /workspace/.mcp.json (merged; coexists with the ADR-053 browser
|
|
300
|
+
* server). Written unconditionally: the adapter passes `--mcp-config`.
|
|
301
|
+
* - Codex — mcp-remote shims appended once per slug to config.toml.
|
|
302
|
+
* - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
|
|
303
|
+
* `--approve-mcps`).
|
|
304
|
+
* - Grok — `grok mcp add` writes ~/.grok/config.toml (idempotent per slug).
|
|
305
|
+
* Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
|
|
297
306
|
*/
|
|
298
307
|
export async function setupMcpTaskConfig(
|
|
299
308
|
taskId: string,
|
|
300
309
|
containerName: string,
|
|
301
310
|
connections: TaskMcpConnection[],
|
|
302
|
-
|
|
311
|
+
engineKinds: string[],
|
|
303
312
|
): Promise<void> {
|
|
304
313
|
// No early return on empty: the claude adapter passes
|
|
305
314
|
// `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
|
|
@@ -308,18 +317,25 @@ export async function setupMcpTaskConfig(
|
|
|
308
317
|
const acl = ensureTaskGatewayAcl(taskId, connections);
|
|
309
318
|
const urlFor = (slug: string): string =>
|
|
310
319
|
`http://host.docker.internal:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
|
|
320
|
+
const has = (kind: string): boolean => engineKinds.includes(kind);
|
|
311
321
|
|
|
312
322
|
const claudeEntries: Record<string, unknown> = {};
|
|
323
|
+
const cursorEntries: Record<string, unknown> = {};
|
|
313
324
|
for (const c of connections) {
|
|
314
325
|
claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
|
|
326
|
+
// Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
|
|
327
|
+
cursorEntries[c.slug] = { url: urlFor(c.slug) };
|
|
315
328
|
}
|
|
316
329
|
const steps = [
|
|
317
330
|
"mkdir -p /workspace/.claude",
|
|
318
331
|
`[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
|
|
319
332
|
JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
|
|
320
333
|
)} > /workspace/.claude/settings.json`,
|
|
321
|
-
`node -e ${shellQuote(MERGE_MCP_JSON)} ${shellQuote(
|
|
322
|
-
|
|
334
|
+
`node -e ${shellQuote(MERGE_MCP_JSON)} /workspace/.mcp.json ${shellQuote(
|
|
335
|
+
JSON.stringify(claudeEntries),
|
|
336
|
+
)}`,
|
|
337
|
+
// Codex: stdio-only, so each connection is an mcp-remote shim.
|
|
338
|
+
...(has("codex")
|
|
323
339
|
? connections.map(
|
|
324
340
|
(c) =>
|
|
325
341
|
`grep -q "mcp_servers.${c.slug}]" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(
|
|
@@ -327,6 +343,23 @@ export async function setupMcpTaskConfig(
|
|
|
327
343
|
)} >> /home/node/.codex/config.toml`,
|
|
328
344
|
)
|
|
329
345
|
: []),
|
|
346
|
+
// Cursor: reads ~/.cursor/mcp.json (the adapter passes --approve-mcps).
|
|
347
|
+
...(has("cursor") && connections.length > 0
|
|
348
|
+
? [
|
|
349
|
+
`node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.cursor/mcp.json ${shellQuote(
|
|
350
|
+
JSON.stringify(cursorEntries),
|
|
351
|
+
)}`,
|
|
352
|
+
]
|
|
353
|
+
: []),
|
|
354
|
+
// Grok: `grok mcp add` writes ~/.grok/config.toml. Idempotent + tolerant.
|
|
355
|
+
...(has("grok")
|
|
356
|
+
? connections.map(
|
|
357
|
+
(c) =>
|
|
358
|
+
`/home/node/.local/bin/grok mcp add ${shellQuote(c.slug)} ${shellQuote(
|
|
359
|
+
urlFor(c.slug),
|
|
360
|
+
)} -t http -s user >/dev/null 2>&1 || true`,
|
|
361
|
+
)
|
|
362
|
+
: []),
|
|
330
363
|
].join(" && ");
|
|
331
364
|
const result = await dockerCli(
|
|
332
365
|
["exec", containerName, "sh", "-lc", steps],
|
package/lib/orchestrator.ts
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
writeAgentCli,
|
|
52
52
|
} from "./agent-cli";
|
|
53
53
|
import { setupBrowserTesting } from "./browser-testing";
|
|
54
|
-
import { setupMcpTaskConfig } from "./mcp-gateway";
|
|
54
|
+
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
55
55
|
import { env } from "./env";
|
|
56
56
|
import type {
|
|
57
57
|
ChannelEnsureInput,
|
|
@@ -172,6 +172,14 @@ class Orchestrator {
|
|
|
172
172
|
/** Fold a fresh channel spec into a live channel (ADR-049). */
|
|
173
173
|
private refreshChannel(channel: Channel, spec: ChannelEnsureInput): void {
|
|
174
174
|
const known = new Set(channel.roster.map((a) => a.id));
|
|
175
|
+
// Replace EXISTING agents' data with the fresh spec — a mid-task roster edit
|
|
176
|
+
// can change permissions, model, brief, role or skills, and without this the
|
|
177
|
+
// stale snapshot persists (e.g. granting `uai` CLI permissions mid-task never
|
|
178
|
+
// took effect: writeAgentCli kept seeing the old empty list). Swapping the
|
|
179
|
+
// object (not merging) also drops fields that were removed. The token/model a
|
|
180
|
+
// LIVE session already carries only changes on its next respawn.
|
|
181
|
+
const bySpecId = new Map(spec.agents.map((a) => [a.id, a]));
|
|
182
|
+
channel.roster = channel.roster.map((a) => bySpecId.get(a.id) ?? a);
|
|
175
183
|
for (const agent of spec.agents) {
|
|
176
184
|
if (!known.has(agent.id)) {
|
|
177
185
|
channel.roster.push(agent);
|
|
@@ -321,7 +329,7 @@ class Orchestrator {
|
|
|
321
329
|
channel.taskId,
|
|
322
330
|
channel.containerName,
|
|
323
331
|
channel.mcpConnections,
|
|
324
|
-
channel.roster.
|
|
332
|
+
channel.roster.map((a) => a.kind),
|
|
325
333
|
);
|
|
326
334
|
// Agent CLIs read MCP servers once, at process start — and durable
|
|
327
335
|
// sessions (ADR-061) make processes long-lived, so without this a
|
|
@@ -796,6 +804,42 @@ class Orchestrator {
|
|
|
796
804
|
for (const session of ch.sessions.values()) await session.close();
|
|
797
805
|
this.channels.delete(taskId);
|
|
798
806
|
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Stop a task's containers but KEEP its worktree + state — a resumable pause
|
|
810
|
+
* the host operator triggers from the local UI (ADR-028). Closes the channel
|
|
811
|
+
* (drops sessions) and stops every container in the compose project, then
|
|
812
|
+
* mirrors `stopped`. Unlike taskDown this destroys nothing, so the cloud
|
|
813
|
+
* reconciles it to `stopped` (worktree present) and it can be resumed. Since
|
|
814
|
+
* `stopped` is not an ACTIVE status, host recovery won't restart it.
|
|
815
|
+
*/
|
|
816
|
+
async stopTask(taskId: string): Promise<{ ok: boolean; error?: string }> {
|
|
817
|
+
const task = getHostTask(taskId);
|
|
818
|
+
if (!task) return { ok: false, error: "unknown task" };
|
|
819
|
+
const project = task.composeProject;
|
|
820
|
+
if (!project) return { ok: false, error: "task has no running stack" };
|
|
821
|
+
// Close sessions first so nothing races to respawn them mid-stop.
|
|
822
|
+
await this.closeChannel(taskId);
|
|
823
|
+
const ps = await dockerCli([
|
|
824
|
+
"ps",
|
|
825
|
+
"-q",
|
|
826
|
+
"--filter",
|
|
827
|
+
`label=com.docker.compose.project=${project}`,
|
|
828
|
+
]);
|
|
829
|
+
const ids = ps.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
830
|
+
if (ids.length > 0) {
|
|
831
|
+
const stopped = await dockerCli(["stop", ...ids], { timeoutMs: 60_000 });
|
|
832
|
+
if (stopped.status !== 0) {
|
|
833
|
+
return {
|
|
834
|
+
ok: false,
|
|
835
|
+
error: stopped.stderr.trim().slice(0, 200) || "docker stop failed",
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
upsertHostTask(taskId, { statusMirror: "stopped" });
|
|
840
|
+
clearTaskGatewayAcl(taskId);
|
|
841
|
+
return { ok: true };
|
|
842
|
+
}
|
|
799
843
|
}
|
|
800
844
|
|
|
801
845
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Diogo Perillo <diogo.perillo@gmail.com>",
|
|
@@ -75,10 +75,10 @@
|
|
|
75
75
|
"zod": "^3.23.8"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
|
79
|
-
"@typescript-eslint/parser": "^8.59.4",
|
|
80
78
|
"@types/node": "^22.9.0",
|
|
81
79
|
"@types/ws": "^8.18.1",
|
|
80
|
+
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
|
81
|
+
"@typescript-eslint/parser": "^8.59.4",
|
|
82
82
|
"eslint": "^9.14.0",
|
|
83
83
|
"typescript": "^5.6.3",
|
|
84
84
|
"vitest": "^2.1.4"
|
package/src/main.ts
CHANGED
|
@@ -183,6 +183,16 @@ async function startLocalUi(): Promise<void> {
|
|
|
183
183
|
// Engine connect/disconnect in the local UI re-advertises capabilities so
|
|
184
184
|
// the cloud's task picker reflects a newly-configured engine promptly.
|
|
185
185
|
readvertise: sendCapabilities,
|
|
186
|
+
// Local UI "Stop" — the operator can pause a task's containers (ADR-028).
|
|
187
|
+
// On success, push the status up so the cloud mirrors `stopped` (it has no
|
|
188
|
+
// auto-reconcile trigger); otherwise it would keep showing `running`.
|
|
189
|
+
stopTask: async (taskId) => {
|
|
190
|
+
const result = await getOrchestrator().stopTask(taskId);
|
|
191
|
+
if (result.ok && ws && ws.readyState === WebSocket.OPEN) {
|
|
192
|
+
send(ws, { kind: "task.status", taskId, status: "stopped" });
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
},
|
|
186
196
|
});
|
|
187
197
|
console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
|
|
188
198
|
} catch (err) {
|
package/src/protocol.ts
CHANGED
|
@@ -501,6 +501,9 @@ export type HostToCloud =
|
|
|
501
501
|
result: HostCommandResult<unknown>;
|
|
502
502
|
}
|
|
503
503
|
| { kind: "event"; event: HostEvent }
|
|
504
|
+
// Host-pushed task lifecycle (ADR-028 local-UI Stop): the host operator paused
|
|
505
|
+
// a task's containers, so the cloud mirrors the status (currently "stopped").
|
|
506
|
+
| { kind: "task.status"; taskId: string; status: string }
|
|
504
507
|
| { kind: "ping"; ts: number }
|
|
505
508
|
| {
|
|
506
509
|
kind: "tunnel.ack";
|
package/src/ui/server.ts
CHANGED
|
@@ -71,6 +71,12 @@ export interface UiServerOptions {
|
|
|
71
71
|
* task picker promptly. Optional (tests omit it).
|
|
72
72
|
*/
|
|
73
73
|
readvertise?: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Stop a task's containers (resumable) — wired from main.ts to the
|
|
76
|
+
* orchestrator. The host operator's "reclaim my machine" control (ADR-028).
|
|
77
|
+
* Optional (tests omit it).
|
|
78
|
+
*/
|
|
79
|
+
stopTask?: (taskId: string) => Promise<{ ok: boolean; error?: string }>;
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
export interface UiServerHandle {
|
|
@@ -147,6 +153,8 @@ async function handle(
|
|
|
147
153
|
return await handleEngineConnect(req, res, opts);
|
|
148
154
|
case "/api/engines/disconnect":
|
|
149
155
|
return await handleEngineDisconnect(req, res, opts);
|
|
156
|
+
case "/api/tasks/stop":
|
|
157
|
+
return await handleTaskStop(req, res, opts);
|
|
150
158
|
}
|
|
151
159
|
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
152
160
|
}
|
|
@@ -260,6 +268,27 @@ async function handleEngineDisconnect(
|
|
|
260
268
|
return sendJson(res, EngineOpResponse, { ok: true });
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
/** POST /api/tasks/stop `{taskId}` → compose-stop the task (resumable). */
|
|
272
|
+
async function handleTaskStop(
|
|
273
|
+
req: IncomingMessage,
|
|
274
|
+
res: ServerResponse,
|
|
275
|
+
opts: UiServerOptions,
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const body = await readJsonBody(req);
|
|
278
|
+
const taskId = body?.taskId;
|
|
279
|
+
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
280
|
+
return sendError(res, 400, "missing taskId");
|
|
281
|
+
}
|
|
282
|
+
if (!opts.stopTask) {
|
|
283
|
+
return sendError(res, 501, "stop not available on this host");
|
|
284
|
+
}
|
|
285
|
+
const result = await opts.stopTask(taskId);
|
|
286
|
+
return sendJson(res, EngineOpResponse, {
|
|
287
|
+
ok: result.ok,
|
|
288
|
+
message: result.error,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
263
292
|
/** Read a request body and parse it as a JSON object; null on empty/invalid. */
|
|
264
293
|
async function readJsonBody(
|
|
265
294
|
req: IncomingMessage,
|
|
@@ -365,6 +394,8 @@ const STATIC_FILES: Record<string, string> = {
|
|
|
365
394
|
"/index.html": "index.html",
|
|
366
395
|
"/style.css": "style.css",
|
|
367
396
|
"/app.js": "app.js",
|
|
397
|
+
"/uai-wheel.svg": "uai-wheel.svg",
|
|
398
|
+
"/uai-favicon.svg": "uai-favicon.svg",
|
|
368
399
|
"/uai-logo-black.svg": "uai-logo-black.svg",
|
|
369
400
|
};
|
|
370
401
|
|
package/ui/app.js
CHANGED
|
@@ -172,7 +172,30 @@ function taskNode(t, events) {
|
|
|
172
172
|
chev.className = "chev";
|
|
173
173
|
chev.textContent = "›";
|
|
174
174
|
|
|
175
|
-
head.append(main
|
|
175
|
+
head.append(main);
|
|
176
|
+
// Stop — pause a running task's containers (resumable). Owner-agnostic: the
|
|
177
|
+
// operator controls what runs on their machine (ADR-028).
|
|
178
|
+
if (t.status === "running" || t.status === "starting") {
|
|
179
|
+
const stop = document.createElement("button");
|
|
180
|
+
stop.className = "link-btn danger task-stop";
|
|
181
|
+
stop.type = "button";
|
|
182
|
+
stop.textContent = "Stop";
|
|
183
|
+
stop.addEventListener("click", async (ev) => {
|
|
184
|
+
ev.stopPropagation();
|
|
185
|
+
if (!confirm(`Stop this task?\n\n${t.taskId}\n\nContainers stop (freeing memory); the worktree is kept so it can be resumed.`)) return;
|
|
186
|
+
stop.disabled = true;
|
|
187
|
+
stop.textContent = "Stopping…";
|
|
188
|
+
try {
|
|
189
|
+
const r = await postJSON("/api/tasks/stop", { taskId: t.taskId });
|
|
190
|
+
if (!r.ok) alert(`Stop failed: ${r.message || "unknown error"}`);
|
|
191
|
+
} catch {
|
|
192
|
+
/* poll re-syncs truth */
|
|
193
|
+
}
|
|
194
|
+
await poll();
|
|
195
|
+
});
|
|
196
|
+
head.append(stop);
|
|
197
|
+
}
|
|
198
|
+
head.append(chev);
|
|
176
199
|
node.append(head);
|
|
177
200
|
|
|
178
201
|
if (open) {
|
package/ui/index.html
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Uai host</title>
|
|
7
7
|
<!-- Local monitor UI (ADR-028). Served from 127.0.0.1 by the host service. -->
|
|
8
|
-
<link rel="icon" type="image/svg+xml" href="/uai-
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="/uai-favicon.svg" />
|
|
9
9
|
<link rel="stylesheet" href="/style.css" />
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<main>
|
|
13
13
|
<header class="topbar">
|
|
14
14
|
<div class="brand">
|
|
15
|
-
<img class="logo" src="/uai-
|
|
15
|
+
<img class="logo" src="/uai-wheel.svg" alt="Uai" />
|
|
16
16
|
<div class="brand-text">
|
|
17
17
|
<div class="host-name" id="host-name">…</div>
|
|
18
18
|
<div class="host-sub" id="host-sub">host monitor</div>
|
package/ui/style.css
CHANGED
|
@@ -280,6 +280,18 @@ code,
|
|
|
280
280
|
color: var(--fg-muted);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
.task-stop {
|
|
284
|
+
flex: none;
|
|
285
|
+
font-size: 0.85rem;
|
|
286
|
+
opacity: 0;
|
|
287
|
+
transition: opacity 0.12s ease;
|
|
288
|
+
}
|
|
289
|
+
/* Reveal Stop on row hover; keep it visible mid-action so it doesn't vanish. */
|
|
290
|
+
.task-head:hover .task-stop,
|
|
291
|
+
.task-stop:disabled {
|
|
292
|
+
opacity: 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
283
295
|
.chev {
|
|
284
296
|
color: var(--fg-muted);
|
|
285
297
|
transition: transform 0.15s ease;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 341.02 341.02">
|
|
3
|
+
<defs>
|
|
4
|
+
<style>
|
|
5
|
+
.cls-1 {
|
|
6
|
+
fill: #231f20;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.cls-2 {
|
|
10
|
+
fill: #fff;
|
|
11
|
+
}
|
|
12
|
+
</style>
|
|
13
|
+
</defs>
|
|
14
|
+
<g id="Layer_1-2" data-name="Layer 1">
|
|
15
|
+
<rect class="cls-1" x="0" width="341.02" height="341.02"/>
|
|
16
|
+
<g>
|
|
17
|
+
<path class="cls-2" d="M170.51,43.13c-70.35,0-127.37,57.03-127.37,127.37s57.03,127.37,127.37,127.37,127.37-57.03,127.37-127.37-57.03-127.37-127.37-127.37ZM170.51,269.54c-54.69,0-99.03-44.34-99.03-99.03s44.34-99.03,99.03-99.03,99.03,44.34,99.03,99.03-44.34,99.03-99.03,99.03Z"/>
|
|
18
|
+
<path class="cls-2" d="M170.59,99.55c-4.31,0-8.53.4-12.63,1.14l-53.93,95.15c3.15,8.33,7.81,15.91,13.65,22.41h105.8c5.74-6.39,10.34-13.82,13.48-21.98l-54.22-95.67c-3.96-.68-8.02-1.06-12.17-1.06ZM194.08,169.79c0,13.02-10.55,23.57-23.57,23.57s-23.57-10.55-23.57-23.57,10.55-23.57,23.57-23.57,23.57,10.55,23.57,23.57Z"/>
|
|
19
|
+
</g>
|
|
20
|
+
</g>
|
|
21
|
+
</svg>
|
package/ui/uai-wheel.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!-- Uai "wheel" mark (brand 8a5db40). Dark fill on transparent bg so the
|
|
3
|
+
header's dark-mode `filter: invert(1)` renders it white on dark. -->
|
|
4
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 341.02 341.02">
|
|
5
|
+
<g fill="#231f20">
|
|
6
|
+
<path d="M170.51,43.13c-70.35,0-127.37,57.03-127.37,127.37s57.03,127.37,127.37,127.37,127.37-57.03,127.37-127.37-57.03-127.37-127.37-127.37ZM170.51,269.54c-54.69,0-99.03-44.34-99.03-99.03s44.34-99.03,99.03-99.03,99.03,44.34,99.03,99.03-44.34,99.03-99.03,99.03Z"/>
|
|
7
|
+
<path d="M170.59,99.55c-4.31,0-8.53.4-12.63,1.14l-53.93,95.15c3.15,8.33,7.81,15.91,13.65,22.41h105.8c5.74-6.39,10.34-13.82,13.48-21.98l-54.22-95.67c-3.96-.68-8.02-1.06-12.17-1.06ZM194.08,169.79c0,13.02-10.55,23.57-23.57,23.57s-23.57-10.55-23.57-23.57,10.55-23.57,23.57-23.57,23.57,10.55,23.57,23.57Z"/>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|