@4xeoz/re-entry 0.2.5 → 0.2.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/README.md CHANGED
@@ -16,6 +16,16 @@ Codex may read and write.
16
16
  npx @4xeoz/re-entry install
17
17
  ```
18
18
 
19
+ `npx` runs a temporary copy, so it does not leave a permanent shell command behind. Install the
20
+ small CLI globally once if you want to use `re-entry` from any folder afterward:
21
+
22
+ ```sh
23
+ npm install --global @4xeoz/re-entry
24
+ ```
25
+
26
+ The package installs both `re-entry` and the older `reentry` spelling; `re-entry` is the documented
27
+ command.
28
+
19
29
  This uses the deployed Re-entry Cloud Receiver by default:
20
30
  `https://reentry-cloud.vercel.app`.
21
31
 
@@ -74,11 +84,13 @@ preview.
74
84
 
75
85
  ```sh
76
86
  re-entry status
87
+ re-entry listen
77
88
  re-entry --help
78
89
  ```
79
90
 
80
91
  The status view checks the local authorization, background job, Receiver reachability, Node, and
81
- Codex. Useful development commands are:
92
+ Codex. `listen` watches the already-running background Connector and displays new activity until
93
+ you press Ctrl+C; it does not start a competing second poller. Useful development commands are:
82
94
 
83
95
  ```sh
84
96
  re-entry doctor --codex-cd /absolute/path/to/project
@@ -87,6 +99,15 @@ re-entry claim-once --codex-cd /absolute/path/to/project
87
99
  re-entry start --codex-cd /absolute/path/to/project
88
100
  ```
89
101
 
102
+ Test the local Codex handoff without waiting for Cloud work:
103
+
104
+ ```sh
105
+ re-entry test "Reply with: Re-entry is working."
106
+ ```
107
+
108
+ This starts one fresh local Codex process through the same adapter seam used by real deliveries. It
109
+ does not create a Grant, claim Receiver work, or prove the browser/WebMCP return path.
110
+
90
111
  To pause or remove the local Connector:
91
112
 
92
113
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4xeoz/re-entry",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Outbound Local Connector process for the Re-entry Core local preview.",
6
6
  "publishConfig": {
@@ -10,7 +10,10 @@
10
10
  "README.md",
11
11
  "src"
12
12
  ],
13
- "bin": "./src/main.mjs",
13
+ "bin": {
14
+ "re-entry": "./src/main.mjs",
15
+ "reentry": "./src/main.mjs"
16
+ },
14
17
  "exports": {
15
18
  ".": "./src/index.mjs",
16
19
  "./connector": "./src/local-connector.mjs",
@@ -0,0 +1,107 @@
1
+ import { open, stat } from "node:fs/promises";
2
+
3
+ const MAX_LINE_BYTES = 16 * 1_024;
4
+ const MAX_READ_BYTES = 64 * 1_024;
5
+
6
+ /**
7
+ * Follow new JSON events written by the installed background Connector.
8
+ * The monitor starts at the end of each file and never starts another Connector poller.
9
+ */
10
+ export async function followConnectorActivity(options) {
11
+ requireOptions(options);
12
+ const cursors = await Promise.all(options.paths.map(initializeCursor));
13
+ const pollIntervalMs = options.pollIntervalMs ?? 350;
14
+ if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 10 || pollIntervalMs > 5_000) {
15
+ throw new TypeError("Activity monitor poll interval is invalid");
16
+ }
17
+
18
+ while (!options.signal.aborted) {
19
+ for (const cursor of cursors) {
20
+ await readNewEvents(cursor, options.onEvent);
21
+ }
22
+ await waitForNextRead(pollIntervalMs, options.signal);
23
+ }
24
+ }
25
+
26
+ async function initializeCursor(path) {
27
+ try {
28
+ const metadata = await stat(path);
29
+ return { path, offset: metadata.size, pending: "" };
30
+ } catch (error) {
31
+ if (error?.code === "ENOENT") return { path, offset: 0, pending: "" };
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ async function readNewEvents(cursor, onEvent) {
37
+ let handle;
38
+ try {
39
+ handle = await open(cursor.path, "r");
40
+ const metadata = await handle.stat();
41
+ if (metadata.size < cursor.offset) {
42
+ cursor.offset = 0;
43
+ cursor.pending = "";
44
+ }
45
+ if (metadata.size === cursor.offset) return;
46
+ const length = Math.min(metadata.size - cursor.offset, MAX_READ_BYTES);
47
+ const buffer = Buffer.alloc(length);
48
+ const { bytesRead } = await handle.read(buffer, 0, length, cursor.offset);
49
+ cursor.offset += bytesRead;
50
+ const lines = `${cursor.pending}${buffer.subarray(0, bytesRead).toString("utf8")}`.split("\n");
51
+ cursor.pending = lines.pop() ?? "";
52
+ if (Buffer.byteLength(cursor.pending, "utf8") > MAX_LINE_BYTES) cursor.pending = "";
53
+ for (const line of lines) {
54
+ const event = parseEvent(line);
55
+ if (event) await onEvent(event);
56
+ }
57
+ } catch (error) {
58
+ if (error?.code !== "ENOENT") throw error;
59
+ } finally {
60
+ await handle?.close();
61
+ }
62
+ }
63
+
64
+ function parseEvent(line) {
65
+ if (line.length === 0 || Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) return null;
66
+ try {
67
+ const value = JSON.parse(line);
68
+ if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.event !== "string") {
69
+ return null;
70
+ }
71
+ return value;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function waitForNextRead(milliseconds, signal) {
78
+ if (signal.aborted) return Promise.resolve();
79
+ return new Promise((resolve) => {
80
+ const timer = setTimeout(done, milliseconds);
81
+ function done() {
82
+ clearTimeout(timer);
83
+ signal.removeEventListener("abort", done);
84
+ resolve();
85
+ }
86
+ signal.addEventListener("abort", done, { once: true });
87
+ });
88
+ }
89
+
90
+ function requireOptions(options) {
91
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
92
+ throw new TypeError("Activity monitor options are required");
93
+ }
94
+ const fields = Object.keys(options);
95
+ if (fields.some((field) => !["paths", "signal", "onEvent", "pollIntervalMs"].includes(field))) {
96
+ throw new TypeError("Activity monitor options contain an unsupported field");
97
+ }
98
+ if (
99
+ !Array.isArray(options.paths) ||
100
+ options.paths.length !== 2 ||
101
+ options.paths.some((path) => typeof path !== "string" || path.length === 0) ||
102
+ !(options.signal instanceof AbortSignal) ||
103
+ typeof options.onEvent !== "function"
104
+ ) {
105
+ throw new TypeError("Activity monitor options are invalid");
106
+ }
107
+ }
@@ -22,6 +22,7 @@ const MIN_COMMAND_TIMEOUT_MS = 100;
22
22
  const MAX_COMMAND_TIMEOUT_MS = 60_000;
23
23
  const MAX_REFERENCE_BYTES = 4 * 1_024;
24
24
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
25
+ const PROMPT_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f]/;
25
26
 
26
27
  /**
27
28
  * Create the fresh-session Codex adapter that lives inside the Local Connector process.
@@ -54,11 +55,11 @@ export function createCodexExecAdapter(options) {
54
55
  return activationResult(activation, "rejected", "activation_rejected", null);
55
56
  }
56
57
 
57
- await runCodexExec({
58
+ await runCodexPrompt({
58
59
  executable,
59
60
  workingDirectory,
60
61
  prompt: buildContinuationPrompt(activation),
61
- timeoutMs: commandTimeoutMs,
62
+ commandTimeoutMs,
62
63
  spawnCommand,
63
64
  });
64
65
  return activationResult(
@@ -71,6 +72,30 @@ export function createCodexExecAdapter(options) {
71
72
  });
72
73
  }
73
74
 
75
+ /**
76
+ * Run one local prompt through the same fresh Codex process seam used by real activations.
77
+ * This helper carries no Receiver authority and is intended only for an explicit local smoke test.
78
+ */
79
+ export async function runCodexPrompt(options) {
80
+ requireExactRecord(
81
+ options,
82
+ ["workingDirectory", "prompt", "executable", "commandTimeoutMs", "spawnCommand"],
83
+ ["workingDirectory", "prompt"],
84
+ "Codex prompt options",
85
+ );
86
+ const workingDirectory = validateCodexWorkingDirectory(options.workingDirectory);
87
+ const executable = options.executable === undefined
88
+ ? discoverCodexExecutable()
89
+ : requireReference(options.executable, "Codex executable");
90
+ const prompt = requirePrompt(options.prompt);
91
+ const timeoutMs = requireTimeout(options.commandTimeoutMs ?? 60_000);
92
+ const spawnCommand = options.spawnCommand ?? spawn;
93
+ if (typeof spawnCommand !== "function") {
94
+ throw new TypeError("Codex prompt spawnCommand must be a function");
95
+ }
96
+ await runCodexExec({ executable, workingDirectory, prompt, timeoutMs, spawnCommand });
97
+ }
98
+
74
99
  function buildContinuationPrompt(activation) {
75
100
  return [
76
101
  "You are a Re-entry continuation agent.",
@@ -183,6 +208,19 @@ function requireReference(value, label) {
183
208
  return value;
184
209
  }
185
210
 
211
+ function requirePrompt(value) {
212
+ if (
213
+ typeof value !== "string" ||
214
+ value.length === 0 ||
215
+ value.trim() !== value ||
216
+ Buffer.byteLength(value, "utf8") > MAX_REFERENCE_BYTES ||
217
+ PROMPT_CONTROL_CHARACTER_PATTERN.test(value)
218
+ ) {
219
+ throw new TypeError("Codex prompt is invalid");
220
+ }
221
+ return value;
222
+ }
223
+
186
224
  function requireExactRecord(value, allowedFields, requiredFields, label) {
187
225
  if (!value || typeof value !== "object" || Array.isArray(value)) {
188
226
  throw new TypeError(`${label} must be an object`);
package/src/main.mjs CHANGED
@@ -9,7 +9,8 @@ import { randomBytes } from "node:crypto";
9
9
  import process from "node:process";
10
10
 
11
11
  import { LocalConnectorClient } from "@webmcp-challenge/reentry-core/local-connector-client";
12
- import { createCodexExecAdapter } from "./codex-exec-adapter.mjs";
12
+ import { followConnectorActivity } from "./activity-monitor.mjs";
13
+ import { createCodexExecAdapter, runCodexPrompt } from "./codex-exec-adapter.mjs";
13
14
  import {
14
15
  discoverCodexExecutable,
15
16
  requireSupportedNode,
@@ -47,8 +48,8 @@ async function main() {
47
48
  process.stdout.write(`${CONNECTOR_VERSION}\n`);
48
49
  return;
49
50
  }
50
- const { command, flags } = parseArguments(argumentsList);
51
- validateCommandFlags(command, flags);
51
+ const { command, flags, positionals } = parseArguments(argumentsList);
52
+ validateCommandFlags(command, flags, positionals);
52
53
  ui = createTerminalUi({ interactive: flags.json !== true && process.stdout.isTTY === true });
53
54
  requireSupportedNode();
54
55
  if (command === "doctor") {
@@ -67,6 +68,14 @@ async function main() {
67
68
  await status(flags, ui);
68
69
  return;
69
70
  }
71
+ if (command === "listen") {
72
+ await listen(flags, ui);
73
+ return;
74
+ }
75
+ if (command === "test") {
76
+ await testCodex(flags, positionals, ui);
77
+ return;
78
+ }
70
79
  if (command === "stop") {
71
80
  await stop(flags, ui);
72
81
  return;
@@ -111,12 +120,14 @@ Connect this Mac once, then receive approved work in Codex in the background.
111
120
 
112
121
  Usage:
113
122
  re-entry install Recommended first run
114
- re-entry status Check the connection
115
- re-entry start Run in this terminal
123
+ re-entry listen Watch live activity
124
+ re-entry test "Reply with hello" Test Codex locally
116
125
 
117
126
  Commands:
118
127
  install Check this Mac, connect the Re-entry account, and start at login
119
128
  status Show account, Receiver, service, Node.js, and Codex readiness
129
+ listen Watch the background Connector until you press Ctrl+C
130
+ test Start one fresh local Codex session with the supplied prompt
120
131
  stop Stop the background Connector without removing its credential
121
132
  uninstall Stop the Connector and remove its local service data
122
133
  connect Authorize this Mac without installing the background service
@@ -134,6 +145,7 @@ Common options:
134
145
  --help Show this help
135
146
  --version Show the installed version
136
147
 
148
+ Both commands are installed: re-entry and reentry.
137
149
  The Connector opens no inbound port. Host keys never belong on this Mac.
138
150
  `);
139
151
  }
@@ -361,6 +373,7 @@ async function status(flags, ui) {
361
373
  ui.next("re-entry status", "Check again when the Re-entry Cloud service is available.");
362
374
  } else {
363
375
  ui.complete("Everything looks good", "Re-entry is ready for approved work.");
376
+ ui.next("re-entry listen", "Watch live activity. Press Ctrl+C when you are done.");
364
377
  }
365
378
  } else {
366
379
  process.stdout.write(`${JSON.stringify({
@@ -378,6 +391,109 @@ async function status(flags, ui) {
378
391
  }
379
392
  }
380
393
 
394
+ async function listen(flags, ui) {
395
+ if (ui.interactive) {
396
+ ui.begin("Live activity", "Watch the background Connector. Ctrl+C closes this view.");
397
+ }
398
+ const service = await inspectMacConnectorService();
399
+ const credentials = await new LocalConnectorCredentialStore({
400
+ filename: defaultCredentialFile(),
401
+ }).load();
402
+ if (!service.running || !credentials) {
403
+ if (ui.interactive) {
404
+ if (!credentials) ui.warning("Account", "not connected");
405
+ if (!service.running) ui.warning("Background", "not running");
406
+ ui.next("re-entry install", "Finish setup and start the background Connector.");
407
+ } else {
408
+ process.stdout.write(`${JSON.stringify({
409
+ event: "connector_listener_unavailable",
410
+ connected: Boolean(credentials),
411
+ service_running: service.running,
412
+ })}\n`);
413
+ }
414
+ return;
415
+ }
416
+
417
+ if (ui.interactive) {
418
+ ui.success("Background", "running");
419
+ ui.success("Account", "connected");
420
+ ui.wait("Listening for approved work…");
421
+ } else {
422
+ process.stdout.write('{"event":"connector_listener_started"}\n');
423
+ }
424
+
425
+ const stopSignal = createStopSignal();
426
+ try {
427
+ await followConnectorActivity({
428
+ paths: defaultConnectorLogFiles(),
429
+ signal: stopSignal.signal,
430
+ onEvent(event) {
431
+ if (ui.interactive) reportLiveActivity(event, ui);
432
+ else process.stdout.write(`${JSON.stringify(event)}\n`);
433
+ },
434
+ });
435
+ } finally {
436
+ stopSignal.close();
437
+ if (ui.interactive) {
438
+ ui.stopWait("Live view closed", "the background Connector is still running", "info");
439
+ } else {
440
+ process.stdout.write('{"event":"connector_listener_stopped"}\n');
441
+ }
442
+ }
443
+ }
444
+
445
+ async function testCodex(flags, positionals, ui) {
446
+ const prompt = requireTestPrompt(positionals);
447
+ const runtimeFlags = await withWorkspaceDirectory(flags, ui);
448
+ const readiness = inspectReadiness(runtimeFlags);
449
+ if (ui.interactive) {
450
+ ui.begin("Test Codex", "Run one local prompt through the Re-entry Codex adapter.");
451
+ ui.success("Codex", "ready");
452
+ ui.success("Workspace", readiness.workingDirectory);
453
+ ui.info("Prompt", prompt);
454
+ ui.wait("Starting a fresh Codex session…");
455
+ }
456
+ await runCodexPrompt({
457
+ workingDirectory: readiness.workingDirectory,
458
+ executable: readiness.installation.executable,
459
+ prompt,
460
+ commandTimeoutMs: readBoundedNumber(
461
+ flags["activation-timeout"] ?? 60_000,
462
+ 100,
463
+ 60_000,
464
+ "connector_activation_timeout_invalid",
465
+ ),
466
+ });
467
+ if (ui.interactive) {
468
+ ui.stopWait("Codex", "completed the local test");
469
+ ui.complete("Test passed", "The same fresh-session process seam is ready for Re-entry work.");
470
+ ui.next("re-entry listen", "Watch the background Connector for approved work.");
471
+ } else {
472
+ process.stdout.write('{"event":"connector_codex_test_passed"}\n');
473
+ }
474
+ }
475
+
476
+ function reportLiveActivity(event, ui) {
477
+ if (event.event === "connector_waiting") return;
478
+ if (event.event === "connector_activation_result") {
479
+ const accepted = event.outcome === "accepted";
480
+ ui.stopWait(
481
+ accepted ? "Work received" : "Work finished",
482
+ accepted ? "a fresh Codex session was started" : String(event.outcome ?? "unknown"),
483
+ accepted ? "success" : "warning",
484
+ );
485
+ } else if (event.event === "connector_poll_failed" || event.event === "local_connector_failed") {
486
+ ui.stopWait("Connection interrupted", "Re-entry is retrying", "warning");
487
+ } else if (event.event === "connector_ready") {
488
+ ui.stopWait("Connector", "ready", "success");
489
+ } else if (event.event === "connector_stopped") {
490
+ ui.stopWait("Background", "stopped", "warning");
491
+ } else {
492
+ ui.stopWait("Activity", event.event.replaceAll("_", " "), "info");
493
+ }
494
+ ui.wait("Listening for approved work…");
495
+ }
496
+
381
497
  async function stop(flags, ui) {
382
498
  if (ui.interactive) {
383
499
  ui.begin("Stop the Connector", "Pause background delivery without removing your account connection");
@@ -442,7 +558,7 @@ async function install(flags, ui) {
442
558
  if (ui.interactive) {
443
559
  ui.stopWait("Background", "running at login");
444
560
  ui.complete("You're all set", "Re-entry is connected and waiting for approved work.");
445
- ui.next("re-entry status", "Check the connection at any time.");
561
+ ui.next("re-entry listen", "Watch live activity. Press Ctrl+C when you are done.");
446
562
  } else {
447
563
  process.stdout.write(`${JSON.stringify({
448
564
  event: "connector_service_installed",
@@ -668,9 +784,14 @@ function parseArguments(argumentsList) {
668
784
  ...(hasExplicitCommand ? argumentsList.slice(commandIndex + 1) : argumentsList.slice(commandIndex)),
669
785
  ];
670
786
  const flags = {};
787
+ const positionals = [];
671
788
  for (let index = 0; index < rest.length; index += 1) {
672
789
  const value = rest[index];
673
- if (!value.startsWith("--")) throw cliFailure("connector_argument_invalid");
790
+ if (!value.startsWith("--")) {
791
+ if (command !== "test") throw cliFailure("connector_argument_invalid");
792
+ positionals.push(value);
793
+ continue;
794
+ }
674
795
  const name = value.slice(2);
675
796
  if (name === "json" || name === "yes") {
676
797
  if (Object.hasOwn(flags, name)) throw cliFailure("connector_argument_invalid");
@@ -682,15 +803,17 @@ function parseArguments(argumentsList) {
682
803
  flags[name] = next;
683
804
  index += 1;
684
805
  }
685
- return { command, flags };
806
+ return { command, flags, positionals };
686
807
  }
687
808
 
688
- function validateCommandFlags(command, flags) {
809
+ function validateCommandFlags(command, flags, positionals) {
689
810
  const allowedByCommand = {
690
811
  doctor: new Set(["codex-binary", "codex-cd", "json"]),
691
812
  pair: new Set(["receiver", "code", "credential-file", "json"]),
692
813
  connect: new Set(["receiver", "device-name", "credential-file", "json"]),
693
814
  status: new Set(["credential-file", "codex-cd", "codex-binary", "json"]),
815
+ listen: new Set(["json"]),
816
+ test: new Set(["codex-cd", "codex-binary", "activation-timeout", "json"]),
694
817
  stop: new Set(["json"]),
695
818
  uninstall: new Set(["credential-file", "yes", "json"]),
696
819
  install: new Set([
@@ -725,7 +848,11 @@ function validateCommandFlags(command, flags) {
725
848
  ]),
726
849
  };
727
850
  const allowed = allowedByCommand[command];
728
- if (!allowed || Object.keys(flags).some((name) => !allowed.has(name))) {
851
+ if (
852
+ !allowed ||
853
+ Object.keys(flags).some((name) => !allowed.has(name)) ||
854
+ (command !== "test" && positionals.length > 0)
855
+ ) {
729
856
  throw cliFailure("connector_argument_invalid");
730
857
  }
731
858
  }
@@ -740,6 +867,14 @@ function defaultCredentialFile() {
740
867
  return join(homedir(), ".webmcp-connector", "credentials.json");
741
868
  }
742
869
 
870
+ function defaultConnectorLogFiles() {
871
+ const stateDirectory = join(homedir(), ".webmcp-connector");
872
+ return [
873
+ join(stateDirectory, "connector.log"),
874
+ join(stateDirectory, "connector-error.log"),
875
+ ];
876
+ }
877
+
743
878
  function defaultDeviceName() {
744
879
  const value = hostname().trim();
745
880
  return value.length >= 2 && Buffer.byteLength(value, "utf8") <= 80
@@ -755,6 +890,23 @@ function displayReceiver(origin) {
755
890
  }
756
891
  }
757
892
 
893
+ function requireTestPrompt(positionals) {
894
+ if (!Array.isArray(positionals) || positionals.length !== 1) {
895
+ throw cliFailure("connector_test_prompt_missing");
896
+ }
897
+ const value = positionals[0];
898
+ if (
899
+ typeof value !== "string" ||
900
+ value.trim() !== value ||
901
+ value.length === 0 ||
902
+ Buffer.byteLength(value, "utf8") > 4 * 1_024 ||
903
+ /[\u0000-\u001f\u007f]/.test(value)
904
+ ) {
905
+ throw cliFailure("connector_test_prompt_invalid");
906
+ }
907
+ return value;
908
+ }
909
+
758
910
  function readBoundedNumber(value, minimum, maximum, code) {
759
911
  const number = typeof value === "number" ? value : Number(value);
760
912
  if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
@@ -853,6 +1005,9 @@ function errorHint(error) {
853
1005
  connector_service_load_failed: "run `re-entry install` again; if it still fails, inspect the Connector error log",
854
1006
  connector_uninstall_confirmation_required: "run uninstall interactively and type DELETE, or pass `--yes` in a deliberate script",
855
1007
  connector_service_stop_failed: "check the Connector status and try `re-entry stop` again",
1008
+ connector_test_prompt_missing: "use `re-entry test \"Reply with: Re-entry is working.\"`",
1009
+ connector_test_prompt_invalid: "use one short, single-line prompt inside quotes",
1010
+ connector_activation_timeout_invalid: "use an activation timeout between 100 and 60000 milliseconds",
856
1011
  };
857
1012
  return hints[error?.code] ?? "check the Receiver address and try again";
858
1013
  }
@@ -11,8 +11,11 @@ const SPINNER_FRAMES = ["·", "✦", "✧", "✦"];
11
11
  const RULE = " ─────────────────────────────────────────────";
12
12
 
13
13
  export const REENTRY_WORDMARK = Object.freeze([
14
- " █▀█ █▀▀ █▀▀ █▄░█ ▀█▀ █▀█ █▄█",
15
- " █▀▄ ██▄ ██▄ █░▀█ ░█░ █▀▄ ░█░",
14
+ " ____ _____ _____ _ _ _____ ______ __",
15
+ " | _ \\| ____| | ____| \\ | |_ _| _ \\ \\ / /",
16
+ " | |_) | _| _____| _| | \\| | | | | |_) | \\ V /",
17
+ " | _ <| |__|_____| |___| |\\ | | | | _ < | |",
18
+ " |_| \\_\\_____| |_____|_| \\_| |_| |_| \\_\\ |_|",
16
19
  ]);
17
20
 
18
21
  /**