relay-companion 0.1.70 → 0.1.71

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.
@@ -1432,6 +1432,7 @@
1432
1432
  }
1433
1433
  function openRelayFromUI(id, source = "relay", mode = "open") {
1434
1434
  if (!id || openingIds.has(id)) return;
1435
+ clearRowNote(id); // a previous open's failure text must not outlive the retry
1435
1436
  openingIds.set(id, performance.now());
1436
1437
  for (const el of openingTargets(id)) el.classList.add("opening");
1437
1438
  setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
@@ -1634,6 +1635,14 @@
1634
1635
  if (el) el.textContent = message || "";
1635
1636
  }
1636
1637
  function cssId(id) { return String(id).replace(/"/g, '\\"'); }
1638
+ // The .row-err slot carries both inline errors and the green injection note, and one
1639
+ // relay can be rendered into more than one list, so wipe every copy of the row's slot.
1640
+ function clearRowNote(id) {
1641
+ for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
1642
+ el.textContent = "";
1643
+ el.classList.remove("ok");
1644
+ }
1645
+ }
1637
1646
 
1638
1647
  async function runRowAction(id, btn, fn) {
1639
1648
  if (!btn || btn.disabled) return;
@@ -2711,6 +2720,16 @@
2711
2720
  }, 12000));
2712
2721
  }
2713
2722
  });
2723
+ // An open that failed leaves the relay UNREAD on purpose (it never reached the user),
2724
+ // so the row has to say what happened — otherwise the spinner just stops and the click
2725
+ // looks like it worked. Cleared by the next open of the same row.
2726
+ if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
2727
+ if (!message) return;
2728
+ for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
2729
+ el.classList.remove("ok");
2730
+ el.innerHTML = esc(message);
2731
+ }
2732
+ });
2714
2733
  window.relay.refresh().then((p) => {
2715
2734
  onPayload(p);
2716
2735
  console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
package/overlay/main.cjs CHANGED
@@ -1375,6 +1375,95 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = f
1375
1375
  }, 1000);
1376
1376
  }
1377
1377
 
1378
+ // The CLI's `open` prints ONE pretty-printed JSON object on stdout and sends every
1379
+ // log line to stderr, so a plain parse should suffice — but a single stray stdout
1380
+ // line (a Node warning, a dep's console.log) used to turn a SUCCESSFUL open into a
1381
+ // browser fallback, so recover by slicing the outermost {...} instead of giving up.
1382
+ function parseOpenResult(out) {
1383
+ const text = String(out || "").trim();
1384
+ const read = (raw) => {
1385
+ const parsed = JSON.parse(raw);
1386
+ return {
1387
+ url: parsed.url || null,
1388
+ skipExternalOpen: Boolean(parsed.skipExternalOpen),
1389
+ freshlyForged: Boolean(parsed.claudeFreshlyForged),
1390
+ };
1391
+ };
1392
+ try {
1393
+ return read(text);
1394
+ } catch {}
1395
+ const start = text.indexOf("{");
1396
+ const end = text.lastIndexOf("}");
1397
+ if (start >= 0 && end > start) {
1398
+ try {
1399
+ return read(text.slice(start, end + 1));
1400
+ } catch {}
1401
+ }
1402
+ return { url: null, skipExternalOpen: false, freshlyForged: false };
1403
+ }
1404
+
1405
+ // Both stdout and stderr in the failure log: a Windows field report where stdout
1406
+ // was empty is the whole reason the "exit" -> "close" fix below exists.
1407
+ function tailFor(stream) {
1408
+ return String(stream || "").trim().split("\n").slice(-2).join(" | ");
1409
+ }
1410
+
1411
+ // Whether the `relay claude-hook` runtime is registered in ~/.claude/settings.json.
1412
+ // "Open in current chat" stages a file that ONLY that runtime consumes, so without
1413
+ // it the click is a silent no-op. Loaded lazily like the modules above (install.js
1414
+ // is ESM; this overlay is CommonJS).
1415
+ let claudeHooksModulePromise = null;
1416
+ function loadClaudeHooksModule() {
1417
+ if (!claudeHooksModulePromise) {
1418
+ const installUrl = pathToFileURL(path.join(__dirname, "..", "src", "install.js")).href;
1419
+ claudeHooksModulePromise = import(installUrl).catch((error) => {
1420
+ claudeHooksModulePromise = null;
1421
+ throw error;
1422
+ });
1423
+ }
1424
+ return claudeHooksModulePromise;
1425
+ }
1426
+
1427
+ // A missing or unparseable settings file counts as NOT installed: staging into a
1428
+ // machine with no hook runtime is worse than a fresh open, so fail closed.
1429
+ function claudeHooksInstalled(install) {
1430
+ let settings;
1431
+ try {
1432
+ settings = JSON.parse(fs.readFileSync(install.claudeSettingsPath(), "utf8")) || {};
1433
+ } catch {
1434
+ return false;
1435
+ }
1436
+ const hooks = settings.hooks;
1437
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
1438
+ for (const entries of Object.values(hooks)) {
1439
+ for (const entry of Array.isArray(entries) ? entries : []) {
1440
+ for (const hook of entry && Array.isArray(entry.hooks) ? entry.hooks : []) {
1441
+ if (install.isRelayClaudeHookCommand(hook && hook.command)) return true;
1442
+ }
1443
+ }
1444
+ }
1445
+ return false;
1446
+ }
1447
+
1448
+ // Register the claude-hook runtime for FUTURE Claude sessions (idempotent; it preserves
1449
+ // every user hook). installClaudeHooks defaults its node to process.execPath, which under
1450
+ // Electron is the OVERLAY binary — a hook command Claude could never run, and one that
1451
+ // would make the presence check above pass forever. So resolve a real node first and skip
1452
+ // the repair entirely when the machine has none.
1453
+ function repairClaudeHooks(install) {
1454
+ execFile(process.platform === "win32" ? "where" : "/usr/bin/which", ["node"], (error, out) => {
1455
+ const node = String(out || "").split("\n")[0].trim();
1456
+ if (error || !node) {
1457
+ console.error("[overlay] cannot register the claude-hook runtime: no node found on PATH");
1458
+ return;
1459
+ }
1460
+ const result = install.installClaudeHooks(undefined, install.stableNodePath(node));
1461
+ if (!result || !result.ok) {
1462
+ console.error("[overlay] claude-hook registration failed:", (result && result.reason) || "unknown");
1463
+ }
1464
+ });
1465
+ }
1466
+
1378
1467
  // Click-to-open a Relay row. A relay materializes a REAL native agent session
1379
1468
  // inside the already-running foregrounded host (Claude Desktop or Codex) via the
1380
1469
  // companion CLI's `open` command, so it appears in that app's recents rail. The
@@ -1404,20 +1493,29 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1404
1493
  }
1405
1494
  const row = rowById(materializationId);
1406
1495
  if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
1407
- const finish = () => {
1496
+ // Ack marks the row read AND drops it from the attention queue, so a relay that
1497
+ // never reached the user must NOT be acked — otherwise a failed click silently
1498
+ // retires the notification. The spinner stop, in contrast, owes every path.
1499
+ const finishOpened = () => {
1408
1500
  if (!sent) ackPacket(packetId);
1409
1501
  if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
1410
1502
  };
1503
+ const finishFailed = (message) => {
1504
+ if (win && !win.isDestroyed()) {
1505
+ if (message) win.webContents.send("openError", packetId, message);
1506
+ win.webContents.send("openDone", packetId);
1507
+ }
1508
+ };
1411
1509
  if (!TASK_FEATURES_ALLOWED && (row?.taskId || isRelayTaskWebTarget(row?.actionUrl))) {
1412
1510
  console.error("[overlay] refusing to open a Relay Task in messages-only mode:", packetId);
1413
- return finish();
1511
+ return finishFailed();
1414
1512
  }
1415
- if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finish();
1513
+ if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finishOpened();
1416
1514
  if (row && row.relayNotificationKind === "connector_reauth") {
1417
1515
  shell
1418
1516
  .openExternal(`${webBase()}/app/connectors`)
1419
1517
  .catch((error) => console.error("[overlay] open failed:", error && error.message));
1420
- return finish();
1518
+ return finishOpened();
1421
1519
  }
1422
1520
  frontmostBundleId((bundle) => {
1423
1521
  const host = resolveClickHost(bundle);
@@ -1446,16 +1544,11 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1446
1544
  let err = "";
1447
1545
  child.stdout.on("data", (data) => (out += data));
1448
1546
  child.stderr.on("data", (data) => (err += data));
1449
- child.on("exit", (code) => {
1450
- let url = null;
1451
- let skipExternalOpen = false;
1452
- let freshlyForged = false;
1453
- try {
1454
- const parsed = JSON.parse(out.trim());
1455
- url = parsed.url || null;
1456
- skipExternalOpen = Boolean(parsed.skipExternalOpen);
1457
- freshlyForged = Boolean(parsed.claudeFreshlyForged);
1458
- } catch {}
1547
+ // "close", never "exit": on Windows "exit" routinely fires BEFORE the stdout pipe
1548
+ // drains, so `out` was empty, the parse failed, and a perfectly successful open was
1549
+ // routed to the web fallback — the user got a browser instead of Claude.
1550
+ child.on("close", (code) => {
1551
+ const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
1459
1552
  if (url || skipExternalOpen) {
1460
1553
  // Raise the host so the relay is visible, whichever open path ran (Claude deep
1461
1554
  // link, Codex bridge, or a plain url). This is the fix for "opened in the right
@@ -1469,6 +1562,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1469
1562
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
1470
1563
  }
1471
1564
  }
1565
+ finishOpened();
1472
1566
  } else {
1473
1567
  console.error(
1474
1568
  "[overlay] open failed for",
@@ -1477,22 +1571,25 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1477
1571
  host,
1478
1572
  "code",
1479
1573
  code,
1574
+ "stdout:",
1575
+ tailFor(out),
1480
1576
  "stderr:",
1481
- err.trim().split("\n").slice(-2).join(" | "),
1577
+ tailFor(err),
1482
1578
  );
1483
- // Last-resort web fallback so the click is never a dead end.
1579
+ // Last-resort web fallback so the click is never a dead end — but the relay
1580
+ // did not open where it was meant to, so it stays unread and in the queue.
1484
1581
  const target = row && row.taskId
1485
1582
  ? taskWebUrl(row.taskId)
1486
1583
  : row && row.actionUrl
1487
1584
  ? absoluteUrl(row.actionUrl)
1488
1585
  : webBase();
1489
1586
  shell.openExternal(target).catch((e) => console.error("[overlay] web fallback failed:", e && e.message));
1587
+ finishFailed("Couldn't open in Claude — opened on the web instead.");
1490
1588
  }
1491
- finish();
1492
1589
  });
1493
1590
  child.on("error", (error) => {
1494
1591
  console.error("[overlay] open spawn error:", error && error.message);
1495
- finish();
1592
+ finishFailed("Couldn't open — Relay's open helper failed to start.");
1496
1593
  });
1497
1594
  });
1498
1595
  }
@@ -1594,22 +1691,50 @@ async function openPacketInCurrent(packetId) {
1594
1691
  }
1595
1692
  }
1596
1693
  if (!target) return fallbackFresh();
1597
- try {
1598
- claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
1599
- relayId: packetId,
1600
- senderName: row.senderName || "",
1601
- title: row.title || row.displayTitle || "",
1602
- ...threadInfoFor(row),
1603
- });
1604
- } catch (error) {
1605
- console.error("[overlay] claude injection staging failed:", error && error.message);
1606
- return fallbackFresh();
1607
- }
1608
- // Raise Claude Desktop so the user watches the instruction land; a
1609
- // terminal-only session stays where it is (its window is not ours to raise).
1610
- if (target.source === "desktop") activateHost("claude", bundle);
1611
- confirmInjected("claude");
1612
- finish();
1694
+ const stageNow = () => {
1695
+ try {
1696
+ claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
1697
+ relayId: packetId,
1698
+ senderName: row.senderName || "",
1699
+ title: row.title || row.displayTitle || "",
1700
+ ...threadInfoFor(row),
1701
+ });
1702
+ } catch (error) {
1703
+ console.error("[overlay] claude injection staging failed:", error && error.message);
1704
+ return fallbackFresh();
1705
+ }
1706
+ // Raise Claude Desktop so the user watches the instruction land; a
1707
+ // terminal-only session stays where it is (its window is not ours to raise).
1708
+ if (target.source === "desktop") activateHost("claude", bundle);
1709
+ confirmInjected("claude");
1710
+ finish();
1711
+ };
1712
+ // NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
1713
+ // ever reads the staged file. Where setup never wrote those hooks (Windows installs
1714
+ // where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
1715
+ // succeeds and the click is a silent, acked no-op. Check before staging. Two-arg
1716
+ // then, not .catch: a throw out of stageNow must not re-enter the failure handler
1717
+ // and finish the click twice.
1718
+ loadClaudeHooksModule().then(
1719
+ (install) => {
1720
+ if (claudeHooksInstalled(install)) return stageNow();
1721
+ // Repair for FUTURE sessions — but a Claude session that is already running
1722
+ // loaded its hooks at startup and will never consume a file staged now, so this
1723
+ // click has to take the fresh-open path, the only one that actually shows the
1724
+ // relay. No ack here: openPacket owns the read state for the fresh open.
1725
+ console.error(
1726
+ "[overlay] claude-hook runtime not registered in",
1727
+ install.claudeSettingsPath(),
1728
+ "— 'Open in current chat' would have been a silent no-op; installing it for future sessions and opening a fresh chat instead (restart Claude to use it)",
1729
+ );
1730
+ repairClaudeHooks(install);
1731
+ fallbackFresh();
1732
+ },
1733
+ (error) => {
1734
+ console.error("[overlay] claude-hook runtime check failed:", error && error.message);
1735
+ fallbackFresh();
1736
+ },
1737
+ );
1613
1738
  });
1614
1739
  }
1615
1740
 
@@ -1645,16 +1770,10 @@ function openTaskDetail(taskId) {
1645
1770
  let err = "";
1646
1771
  child.stdout.on("data", (data) => (out += data));
1647
1772
  child.stderr.on("data", (data) => (err += data));
1648
- child.on("exit", (code) => {
1649
- let url = null;
1650
- let skipExternalOpen = false;
1651
- let freshlyForged = false;
1652
- try {
1653
- const parsed = JSON.parse(out.trim());
1654
- url = parsed.url || null;
1655
- skipExternalOpen = Boolean(parsed.skipExternalOpen);
1656
- freshlyForged = Boolean(parsed.claudeFreshlyForged);
1657
- } catch {}
1773
+ // "close", never "exit" — same Windows stdout-drain race as openPacket: an empty
1774
+ // parse used to send a successful task open to the browser.
1775
+ child.on("close", (code) => {
1776
+ const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
1658
1777
  if (url || skipExternalOpen) {
1659
1778
  activateHost(host, bundle); // raise the host so the opened task is visible
1660
1779
  if (url && !skipExternalOpen) {
@@ -1672,8 +1791,10 @@ function openTaskDetail(taskId) {
1672
1791
  host,
1673
1792
  "code",
1674
1793
  code,
1794
+ "stdout:",
1795
+ tailFor(out),
1675
1796
  "stderr:",
1676
- err.trim().split("\n").slice(-2).join(" | "),
1797
+ tailFor(err),
1677
1798
  );
1678
1799
  // Last-resort web fallback so the click is never a dead end.
1679
1800
  shell
@@ -1949,7 +2070,10 @@ function createWindow() {
1949
2070
  },
1950
2071
  });
1951
2072
 
1952
- win.setAlwaysOnTop(true, "floating");
2073
+ // Windows strips WS_EX_TOPMOST from a scheduled-task-launched overlay that only asks
2074
+ // politely ("floating"); "screen-saver" is the level that survives. The periodic
2075
+ // re-assert that recovers from later demotions lives in space-presence.cjs.
2076
+ win.setAlwaysOnTop(true, process.platform === "win32" ? "screen-saver" : "floating");
1953
2077
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
1954
2078
  // Empty canvas is click-through; startHitTest() below owns this from here on.
1955
2079
  win.setIgnoreMouseEvents(true, { forward: true });
@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("relay", {
8
8
  // "Open in current chat" staged an injection: the UI confirms it so the
9
9
  // click has visible feedback (delivery is checkpoint-based, not instant).
10
10
  onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
11
+ // The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
12
+ // say why inline instead of letting the spinner just stop.
13
+ onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
11
14
  onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
12
15
  onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
13
16
 
@@ -18,22 +18,40 @@ function resetWindowZoom(win) {
18
18
  return true;
19
19
  }
20
20
 
21
- function reinforceSpacePresence(win, { moveTop = false } = {}) {
21
+ function reinforceSpacePresence(win, { moveTop = false, platform = process.platform } = {}) {
22
22
  if (!isUsableWindow(win)) return false;
23
23
  resetWindowZoom(win);
24
- // Only touch window-server state that actually drifted. Re-asserting
25
- // setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
26
- // every Space change) reorders the window each time and reads as flicker.
27
- try {
28
- if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
29
- win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
30
- }
31
- } catch {}
32
- try {
33
- if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
34
- win.setAlwaysOnTop(true, "floating");
35
- }
36
- } catch {}
24
+ const isWindows = platform === "win32";
25
+ // All-Spaces is a macOS Spaces concept. On Windows isVisibleOnAllWorkspaces()
26
+ // always reports false, so the drift check below re-called the setter on every
27
+ // 1.5s poll for nothing.
28
+ if (!isWindows) {
29
+ // Only touch window-server state that actually drifted. Re-asserting
30
+ // setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
31
+ // every Space change) reorders the window each time and reads as flicker.
32
+ try {
33
+ if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
34
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
35
+ }
36
+ } catch {}
37
+ }
38
+ if (isWindows) {
39
+ // Re-assert unconditionally: Electron's isAlwaysOnTop() is a cached flag from
40
+ // its own last setAlwaysOnTop call and cannot observe the OS stripping
41
+ // WS_EX_TOPMOST (scheduled-task-launched windows come up without it and sink
42
+ // behind maximized windows). Trusting the getter made the repair a permanent
43
+ // no-op. SetWindowPos-to-topmost on an already-topmost window is not a visible
44
+ // reorder here, so the macOS flicker rationale above does not apply.
45
+ try {
46
+ win.setAlwaysOnTop(true, "screen-saver");
47
+ } catch {}
48
+ } else {
49
+ try {
50
+ if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
51
+ win.setAlwaysOnTop(true, "floating");
52
+ }
53
+ } catch {}
54
+ }
37
55
  // moveTop is macOS orderWindow:NSWindowAbove — on a HIDDEN window that orders it
38
56
  // back on-screen. That made the dismissed pill flash on every Space switch (shown
39
57
  // here, then hidden again by the visibility sync). Raising only means anything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/install.js CHANGED
@@ -501,6 +501,21 @@ export function claudeSettingsPath() {
501
501
  );
502
502
  }
503
503
 
504
+ /**
505
+ * True when Claude looks present on this machine, judged ONLY by the directory
506
+ * that holds claudeSettingsPath() (so CLAUDE_SETTINGS / CLAUDE_HOME overrides are
507
+ * respected for tests). Deliberately not a `claude` CLI probe: Windows installs
508
+ * routinely have Claude Code/Desktop with ~/.claude configured by hand and no
509
+ * `claude` on PATH, and those installs still need the hook runtime.
510
+ */
511
+ export function claudeAppearsPresent({ settingsPath = claudeSettingsPath() } = {}) {
512
+ try {
513
+ return fs.existsSync(path.dirname(settingsPath));
514
+ } catch {
515
+ return false;
516
+ }
517
+ }
518
+
504
519
  function shellArg(value) {
505
520
  const clean = String(value);
506
521
  return /\s/.test(clean) ? `"${clean.replaceAll('"', '\\"')}"` : clean;
@@ -1009,14 +1024,19 @@ export async function runSetupInstall({ mode = DEFAULT_COMPANION_MODE } = {}) {
1009
1024
  if (claude.ok) {
1010
1025
  installed.push("Claude Code");
1011
1026
  activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
1012
- // The Open-in-current-chat hook runtime rides along with the MCP
1013
- // registration (both are what "Relay is installed into Claude" means).
1014
- claudeHooks = installClaudeHooks(bin, node);
1015
1027
  } else if (claude.reason === "claude_code_not_found") {
1016
1028
  missing.push("Claude Code");
1017
1029
  } else {
1018
1030
  missing.push("Claude Code (registration failed)");
1019
1031
  }
1032
+ // The Open-in-current-chat hook runtime rides along with the MCP registration
1033
+ // (both are what "Relay is installed into Claude" means) — but it must NOT
1034
+ // depend on the `claude` CLI being on PATH. On Windows installClaudeCode fails
1035
+ // with claude_code_not_found whenever the CLI is absent even though Claude
1036
+ // Code/Desktop is installed and the MCP was registered by hand; without this
1037
+ // fallback the hook is never written and the pill's "Open in current chat" is a
1038
+ // silent no-op on exactly those machines.
1039
+ if (claude.ok || claudeAppearsPresent()) claudeHooks = installClaudeHooks(bin, node);
1020
1040
  const codex = installCodex(bin, node, { mode: normalizedMode });
1021
1041
  if (codex.ok) {
1022
1042
  installed.push("Codex");