@runuai/host 0.8.4 → 0.8.6

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/src/ui/types.ts CHANGED
@@ -74,5 +74,28 @@ export type UserRow = z.infer<typeof UserRow>;
74
74
  export const UsersResponse = z.object({ users: z.array(UserRow) });
75
75
  export type UsersResponse = z.infer<typeof UsersResponse>;
76
76
 
77
+ // GET /api/engines — the engine catalog + which are connected on this host.
78
+ export const EngineCatalogEntry = z.object({
79
+ kind: z.enum(["claude", "codex", "kimi", "grok", "cursor"]),
80
+ label: z.string(),
81
+ authMode: z.enum(["token-command", "login-command", "api-key"]),
82
+ notes: z.string().nullable(),
83
+ getKeyUrl: z.string().nullable(),
84
+ });
85
+ export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
86
+
87
+ export const EnginesResponse = z.object({
88
+ catalog: z.array(EngineCatalogEntry),
89
+ statuses: z.record(z.boolean()), // kind → connected
90
+ });
91
+ export type EnginesResponse = z.infer<typeof EnginesResponse>;
92
+
93
+ // POST /api/engines/disconnect — the disconnect result.
94
+ export const EngineOpResponse = z.object({
95
+ ok: z.boolean(),
96
+ message: z.string().optional(),
97
+ });
98
+ export type EngineOpResponse = z.infer<typeof EngineOpResponse>;
99
+
77
100
  export const ErrorResponse = z.object({ error: z.string() });
78
101
  export type ErrorResponse = z.infer<typeof ErrorResponse>;
package/ui/app.js CHANGED
@@ -7,6 +7,7 @@ const $ = (id) => document.getElementById(id);
7
7
  const expanded = new Set(); // task ids whose events are expanded (persist across polls)
8
8
  let latestTasks = [];
9
9
  let latestEvents = [];
10
+ let latestEngines = null; // { catalog, statuses } from /api/engines
10
11
 
11
12
  const KIND_CLASS = {
12
13
  "task.created": "created",
@@ -23,18 +24,22 @@ async function getJSON(path) {
23
24
 
24
25
  async function poll() {
25
26
  try {
26
- const [status, cloud, tasks, events] = await Promise.all([
27
+ const [status, cloud, tasks, events, engines] = await Promise.all([
27
28
  getJSON("/api/status"),
28
29
  getJSON("/api/cloud"),
29
30
  getJSON("/api/tasks"),
30
31
  getJSON("/api/events"),
32
+ // Engines are non-fatal: a failure here must not flip the badge to
33
+ // "unreachable" — keep the last known catalog.
34
+ getJSON("/api/engines").catch(() => latestEngines),
31
35
  ]);
32
36
  renderStatus(status);
33
37
  renderCloud(cloud);
34
38
  renderEvents(events.events);
35
39
  renderTasks(tasks.tasks, events.events);
40
+ renderEngines(engines);
36
41
  $("foot-note").textContent =
37
- `read-only · 127.0.0.1 · updated ${new Date().toLocaleTimeString()}`;
42
+ `127.0.0.1 · updated ${new Date().toLocaleTimeString()}`;
38
43
  } catch {
39
44
  setBadge("bad", "unreachable");
40
45
  }
@@ -216,6 +221,378 @@ function renderEvents(events) {
216
221
  }
217
222
  }
218
223
 
224
+ // --- engines ----------------------------------------------------------------
225
+
226
+ async function postJSON(path, body) {
227
+ const res = await fetch(path, {
228
+ method: "POST",
229
+ headers: { "content-type": "application/json" },
230
+ body: JSON.stringify(body),
231
+ });
232
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
233
+ return res.json();
234
+ }
235
+
236
+ function engineByKind(kind) {
237
+ return (latestEngines?.catalog || []).find((e) => e.kind === kind) || null;
238
+ }
239
+
240
+ function monogram(label) {
241
+ const m = document.createElement("span");
242
+ m.className = "engine-mono";
243
+ m.textContent = (label || "?").trim().charAt(0).toUpperCase();
244
+ return m;
245
+ }
246
+
247
+ function renderEngines(data) {
248
+ if (data) latestEngines = data;
249
+ const wrap = $("engines");
250
+ if (!wrap) return;
251
+ const catalog = latestEngines?.catalog || [];
252
+ const statuses = latestEngines?.statuses || {};
253
+ const connected = catalog.filter((e) => statuses[e.kind]);
254
+
255
+ $("engines-count").textContent = connected.length ? `(${connected.length})` : "";
256
+ $("engines-empty").hidden = connected.length > 0;
257
+ wrap.querySelectorAll(".engine-card").forEach((n) => n.remove());
258
+ for (const e of connected) wrap.append(engineCard(e));
259
+ }
260
+
261
+ function engineCard(e) {
262
+ const card = document.createElement("div");
263
+ card.className = "engine-card";
264
+
265
+ const head = document.createElement("div");
266
+ head.className = "engine-head";
267
+ head.append(monogram(e.label));
268
+ const meta = document.createElement("div");
269
+ meta.className = "engine-meta";
270
+ const name = document.createElement("div");
271
+ name.className = "engine-name";
272
+ name.textContent = e.label;
273
+ const sub = document.createElement("div");
274
+ sub.className = "engine-sub ok";
275
+ sub.textContent = "Connected";
276
+ meta.append(name, sub);
277
+ head.append(meta);
278
+ card.append(head);
279
+
280
+ const btn = document.createElement("button");
281
+ btn.className = "link-btn danger";
282
+ btn.type = "button";
283
+ btn.textContent = "Disconnect";
284
+ btn.addEventListener("click", async () => {
285
+ btn.disabled = true;
286
+ btn.textContent = "Disconnecting…";
287
+ try {
288
+ await postJSON("/api/engines/disconnect", { kind: e.kind });
289
+ } catch {
290
+ /* poll re-syncs truth */
291
+ }
292
+ await poll();
293
+ });
294
+ card.append(btn);
295
+ return card;
296
+ }
297
+
298
+ // --- add-engine modal -------------------------------------------------------
299
+
300
+ function openModal() {
301
+ $("engine-modal").hidden = false;
302
+ }
303
+ function closeModal() {
304
+ $("engine-modal").hidden = true;
305
+ $("engine-modal-body").replaceChildren();
306
+ $("engine-modal-title").textContent = "Add engine";
307
+ }
308
+
309
+ function openAddEngine() {
310
+ $("engine-modal-title").textContent = "Add engine";
311
+ const body = $("engine-modal-body");
312
+ body.replaceChildren();
313
+
314
+ const catalog = latestEngines?.catalog || [];
315
+ const statuses = latestEngines?.statuses || {};
316
+ const available = catalog.filter((e) => !statuses[e.kind]);
317
+
318
+ if (available.length === 0) {
319
+ const p = document.createElement("p");
320
+ p.className = "empty";
321
+ p.textContent = "All engines are connected.";
322
+ body.append(p);
323
+ openModal();
324
+ return;
325
+ }
326
+
327
+ const list = document.createElement("div");
328
+ list.className = "engine-picker";
329
+ for (const e of available) {
330
+ const row = document.createElement("button");
331
+ row.className = "engine-pick";
332
+ row.type = "button";
333
+ row.append(monogram(e.label));
334
+ const meta = document.createElement("div");
335
+ meta.className = "engine-meta";
336
+ const name = document.createElement("div");
337
+ name.className = "engine-name";
338
+ name.textContent = e.label;
339
+ if (e.notes) {
340
+ const sub = document.createElement("div");
341
+ sub.className = "engine-sub";
342
+ sub.textContent = e.notes;
343
+ meta.append(name, sub);
344
+ } else {
345
+ meta.append(name);
346
+ }
347
+ row.append(meta);
348
+ const chev = document.createElement("span");
349
+ chev.className = "chev";
350
+ chev.textContent = "›";
351
+ row.append(chev);
352
+ row.addEventListener("click", () => setupEngine(e));
353
+ list.append(row);
354
+ }
355
+ body.append(list);
356
+ openModal();
357
+ }
358
+
359
+ function setupEngine(e) {
360
+ $("engine-modal-title").textContent = `Connect ${e.label}`;
361
+ const body = $("engine-modal-body");
362
+ body.replaceChildren();
363
+
364
+ if (e.authMode === "api-key") {
365
+ body.append(apiKeyForm(e));
366
+ } else {
367
+ body.append(commandForm(e));
368
+ }
369
+
370
+ const back = document.createElement("button");
371
+ back.className = "link-btn back";
372
+ back.type = "button";
373
+ back.textContent = "‹ Back";
374
+ back.addEventListener("click", openAddEngine);
375
+ body.append(back);
376
+ }
377
+
378
+ /** api-key mode (Cursor): a masked field + Save + "get a key" link. */
379
+ function apiKeyForm(e) {
380
+ const form = document.createElement("div");
381
+ form.className = "engine-setup";
382
+
383
+ if (e.notes) {
384
+ const note = document.createElement("p");
385
+ note.className = "setup-note";
386
+ note.textContent = e.notes;
387
+ form.append(note);
388
+ }
389
+
390
+ const field = document.createElement("div");
391
+ field.className = "field";
392
+ const input = document.createElement("input");
393
+ input.type = "password";
394
+ input.className = "text-input";
395
+ input.placeholder = `${e.label} API key`;
396
+ input.autocomplete = "off";
397
+ input.spellcheck = false;
398
+ field.append(input);
399
+ form.append(field);
400
+
401
+ const status = document.createElement("div");
402
+ status.className = "setup-status";
403
+ form.append(status);
404
+
405
+ const actions = document.createElement("div");
406
+ actions.className = "setup-actions";
407
+ const save = document.createElement("button");
408
+ save.className = "btn";
409
+ save.type = "button";
410
+ save.textContent = "Save";
411
+ save.addEventListener("click", async () => {
412
+ const apiKey = input.value.trim();
413
+ if (!apiKey) {
414
+ status.className = "setup-status err";
415
+ status.textContent = "Paste a key first.";
416
+ return;
417
+ }
418
+ save.disabled = true;
419
+ save.textContent = "Saving…";
420
+ const result = await runConnect({ kind: e.kind, apiKey });
421
+ if (result.ok) {
422
+ await poll();
423
+ closeModal();
424
+ } else {
425
+ save.disabled = false;
426
+ save.textContent = "Save";
427
+ status.className = "setup-status err";
428
+ status.textContent = result.message || "Couldn't save the key.";
429
+ }
430
+ });
431
+ actions.append(save);
432
+ if (e.getKeyUrl) {
433
+ const link = document.createElement("a");
434
+ link.className = "link-btn";
435
+ link.href = e.getKeyUrl;
436
+ link.target = "_blank";
437
+ link.rel = "noreferrer";
438
+ link.textContent = "Get a key";
439
+ actions.append(link);
440
+ }
441
+ form.append(actions);
442
+ return form;
443
+ }
444
+
445
+ /** token/login mode: a Connect button + a live log; Claude adds a paste link. */
446
+ function commandForm(e) {
447
+ const form = document.createElement("div");
448
+ form.className = "engine-setup";
449
+
450
+ if (e.notes) {
451
+ const note = document.createElement("p");
452
+ note.className = "setup-note";
453
+ note.textContent = e.notes;
454
+ form.append(note);
455
+ }
456
+
457
+ const status = document.createElement("div");
458
+ status.className = "setup-status";
459
+ const log = document.createElement("pre");
460
+ log.className = "log";
461
+ log.hidden = true;
462
+
463
+ const actions = document.createElement("div");
464
+ actions.className = "setup-actions";
465
+ const connect = document.createElement("button");
466
+ connect.className = "btn";
467
+ connect.type = "button";
468
+ connect.textContent = "Connect";
469
+ connect.addEventListener("click", async () => {
470
+ connect.disabled = true;
471
+ connect.textContent = "Connecting…";
472
+ status.className = "setup-status";
473
+ status.textContent =
474
+ e.authMode === "token-command"
475
+ ? "Your browser will open to authorize…"
476
+ : "Your browser will open to sign in…";
477
+ log.hidden = false;
478
+ log.textContent = "";
479
+ const result = await runConnect({ kind: e.kind }, (line) => {
480
+ log.textContent += (log.textContent ? "\n" : "") + line;
481
+ log.scrollTop = log.scrollHeight;
482
+ });
483
+ if (result.ok) {
484
+ await poll();
485
+ closeModal();
486
+ } else {
487
+ connect.disabled = false;
488
+ connect.textContent = "Try again";
489
+ status.className = "setup-status err";
490
+ status.textContent = result.message || "Connect failed.";
491
+ }
492
+ });
493
+ actions.append(connect);
494
+ form.append(actions, status, log);
495
+
496
+ // Claude: a manual paste fallback for when the browser flow isn't possible.
497
+ if (e.authMode === "token-command") {
498
+ const pasteToggle = document.createElement("button");
499
+ pasteToggle.className = "link-btn";
500
+ pasteToggle.type = "button";
501
+ pasteToggle.textContent = "Paste a token instead";
502
+ const pasteWrap = document.createElement("div");
503
+ pasteWrap.className = "engine-setup";
504
+ pasteWrap.hidden = true;
505
+ const pInput = document.createElement("input");
506
+ pInput.type = "password";
507
+ pInput.className = "text-input";
508
+ pInput.placeholder = "sk-ant-oat…";
509
+ pInput.autocomplete = "off";
510
+ pInput.spellcheck = false;
511
+ const pField = document.createElement("div");
512
+ pField.className = "field";
513
+ pField.append(pInput);
514
+ const pStatus = document.createElement("div");
515
+ pStatus.className = "setup-status";
516
+ const pSave = document.createElement("button");
517
+ pSave.className = "btn";
518
+ pSave.type = "button";
519
+ pSave.textContent = "Save token";
520
+ pSave.addEventListener("click", async () => {
521
+ const pastedToken = pInput.value.trim();
522
+ if (!pastedToken) {
523
+ pStatus.className = "setup-status err";
524
+ pStatus.textContent = "Paste a token first.";
525
+ return;
526
+ }
527
+ pSave.disabled = true;
528
+ pSave.textContent = "Saving…";
529
+ const result = await runConnect({ kind: e.kind, pastedToken });
530
+ if (result.ok) {
531
+ await poll();
532
+ closeModal();
533
+ } else {
534
+ pSave.disabled = false;
535
+ pSave.textContent = "Save token";
536
+ pStatus.className = "setup-status err";
537
+ pStatus.textContent = result.message || "Couldn't save the token.";
538
+ }
539
+ });
540
+ const pActions = document.createElement("div");
541
+ pActions.className = "setup-actions";
542
+ pActions.append(pSave);
543
+ pasteWrap.append(pField, pActions, pStatus);
544
+ pasteToggle.addEventListener("click", () => {
545
+ pasteWrap.hidden = !pasteWrap.hidden;
546
+ });
547
+ form.append(pasteToggle, pasteWrap);
548
+ }
549
+ return form;
550
+ }
551
+
552
+ /**
553
+ * POST /api/engines/connect and consume the NDJSON stream: `{line}` frames feed
554
+ * onLine; the final `{done,ok,message}` frame is the result.
555
+ */
556
+ async function runConnect(body, onLine) {
557
+ let res;
558
+ try {
559
+ res = await fetch("/api/engines/connect", {
560
+ method: "POST",
561
+ headers: { "content-type": "application/json" },
562
+ body: JSON.stringify(body),
563
+ });
564
+ } catch (err) {
565
+ return { ok: false, message: String(err) };
566
+ }
567
+ if (!res.ok || !res.body) {
568
+ return { ok: false, message: `connect failed (${res.status})` };
569
+ }
570
+ const reader = res.body.getReader();
571
+ const dec = new TextDecoder();
572
+ let buf = "";
573
+ let result = null;
574
+ for (;;) {
575
+ const { value, done } = await reader.read();
576
+ if (done) break;
577
+ buf += dec.decode(value, { stream: true });
578
+ let nl;
579
+ while ((nl = buf.indexOf("\n")) >= 0) {
580
+ const raw = buf.slice(0, nl);
581
+ buf = buf.slice(nl + 1);
582
+ if (!raw.trim()) continue;
583
+ let msg;
584
+ try {
585
+ msg = JSON.parse(raw);
586
+ } catch {
587
+ continue;
588
+ }
589
+ if (typeof msg.line === "string" && onLine) onLine(msg.line);
590
+ if (msg.done) result = { ok: !!msg.ok, message: msg.message || "" };
591
+ }
592
+ }
593
+ return result || { ok: false, message: "Connection ended unexpectedly." };
594
+ }
595
+
219
596
  // --- formatting -------------------------------------------------------------
220
597
 
221
598
  function fmtDuration(ms) {
@@ -260,5 +637,15 @@ $("badge").addEventListener("click", () => {
260
637
  $("badge").setAttribute("aria-expanded", String(show));
261
638
  });
262
639
 
640
+ $("add-engine").addEventListener("click", openAddEngine);
641
+ $("engine-modal-close").addEventListener("click", closeModal);
642
+ $("engine-modal").addEventListener("click", (e) => {
643
+ // Click the backdrop (not the dialog) to dismiss.
644
+ if (e.target === $("engine-modal")) closeModal();
645
+ });
646
+ document.addEventListener("keydown", (e) => {
647
+ if (e.key === "Escape" && !$("engine-modal").hidden) closeModal();
648
+ });
649
+
263
650
  poll();
264
651
  setInterval(poll, 2000);
package/ui/index.html CHANGED
@@ -32,6 +32,18 @@
32
32
  <dl class="kv" id="service-kv"></dl>
33
33
  </section>
34
34
 
35
+ <section class="panel">
36
+ <h2>
37
+ Engines <span class="count" id="engines-count"></span>
38
+ <button class="link-btn add-engine" id="add-engine" type="button">
39
+ + Add engine
40
+ </button>
41
+ </h2>
42
+ <div id="engines" class="engines">
43
+ <p class="empty" id="engines-empty">No engines connected.</p>
44
+ </div>
45
+ </section>
46
+
35
47
  <section class="panel">
36
48
  <h2>Active tasks <span class="count" id="tasks-count"></span></h2>
37
49
  <div id="tasks" class="tasks">
@@ -47,9 +59,32 @@
47
59
  </section>
48
60
 
49
61
  <footer class="foot">
50
- <span id="foot-note">read-only · 127.0.0.1 · polls every 2s</span>
62
+ <span id="foot-note">127.0.0.1 · polls every 2s</span>
51
63
  </footer>
52
64
  </main>
65
+
66
+ <div class="modal-backdrop" id="engine-modal" hidden>
67
+ <div
68
+ class="modal"
69
+ role="dialog"
70
+ aria-modal="true"
71
+ aria-labelledby="engine-modal-title"
72
+ >
73
+ <div class="modal-head">
74
+ <h3 id="engine-modal-title">Add engine</h3>
75
+ <button
76
+ class="modal-close"
77
+ id="engine-modal-close"
78
+ type="button"
79
+ aria-label="Close"
80
+ >
81
+ ×
82
+ </button>
83
+ </div>
84
+ <div class="modal-body" id="engine-modal-body"></div>
85
+ </div>
86
+ </div>
87
+
53
88
  <script src="/app.js"></script>
54
89
  </body>
55
90
  </html>